diff --git a/ILSpy.Tests/Editor/FoldingsViewStateTests.cs b/ILSpy.Tests/Editor/FoldingsViewStateTests.cs new file mode 100644 index 000000000..70b76f38a --- /dev/null +++ b/ILSpy.Tests/Editor/FoldingsViewStateTests.cs @@ -0,0 +1,196 @@ +// 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; +using System.Linq; + +using AvaloniaEdit.Folding; + +using AwesomeAssertions; + +using ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// Tests for the foldings-persistence helper that backs Back/Forward navigation's +/// "remember which regions the user had expanded" behaviour. The math mirrors WPF's +/// DecompilerTextViewState.SaveFoldingsState / RestoreFoldings so that +/// the protective "skip on layout mismatch" semantics carry over identically. +/// +[TestFixture] +public class FoldingsViewStateTests +{ + [Test] + public void Capture_Records_Offsets_Of_Expanded_Foldings_Only() + { + // The saved subset is the list of foldings the user has open — folded foldings are + // already at their default state and don't need preserving. Mirrors WPF's + // `foldings.Where(f => !f.IsFolded)` filter at the heart of SaveFoldingsState. + + // Arrange — four foldings, two folded and two expanded. Offsets chosen to disambiguate. + var foldings = new[] { + (Start: 10, End: 50, IsFolded: false), + (Start: 60, End: 100, IsFolded: true), + (Start: 110, End: 200, IsFolded: false), + (Start: 210, End: 250, IsFolded: true), + }; + + // Act — capture the snapshot. + var snapshot = FoldingsViewState.Capture(foldings); + + // Assert — only the expanded pair is recorded, in input order. + snapshot.Expanded.Should().Equal(new[] { (10, 50), (110, 200) }); + } + + [Test] + public void Capture_Checksum_Is_The_Same_For_Layouts_With_Identical_Offsets() + { + // The checksum protects restoration against running on a different document. Two + // foldings lists with the same `(Start, End)` pairs in the same order must produce + // equal checksums regardless of which folds are open vs closed. + + // Arrange — same offsets, different IsFolded states. + var a = new[] { (10, 50, false), (60, 100, true), (110, 200, false) }; + var b = new[] { (10, 50, true), (60, 100, false), (110, 200, true) }; + + // Act — capture both. + var snapA = FoldingsViewState.Capture(a); + var snapB = FoldingsViewState.Capture(b); + + // Assert — checksums match (layout is identical even though state isn't). + snapA.Checksum.Should().Be(snapB.Checksum); + } + + [Test] + public void Capture_Checksum_Differs_When_Any_Offset_Shifts() + { + // A single offset move yields a different checksum — that's the cue that the new + // document doesn't match the saved state, and restoration must skip. Otherwise + // "expanded at (10, 50)" might bogusly re-expand "(10, 51)" in the new document. + + // Arrange — identical layouts except one folding shifted by one byte. + var original = new[] { (10, 50, false), (60, 100, false) }; + var shifted = new[] { (10, 51, false), (60, 100, false) }; + + // Act — capture both. + var checksumOriginal = FoldingsViewState.Capture(original).Checksum; + var checksumShifted = FoldingsViewState.Capture(shifted).Checksum; + + // Assert — checksums differ. + checksumShifted.Should().NotBe(checksumOriginal); + } + + [Test] + public void Restore_Reopens_Saved_Expanded_Foldings_When_Checksum_Matches() + { + // On Back navigation: the freshly-built foldings list comes back default-open + // (DefaultClosed=false on every NewFolding). Restore must close every folding that + // wasn't in the saved expanded set, and leave the saved-expanded ones open. This is + // the inverse of how WPF restores — we don't track "what was closed", only "what was + // open" — so anything not on the open list defaults to closed. + + // Arrange — three new foldings (all start at DefaultClosed=false), with a saved + // snapshot saying only the middle one was expanded. + var newFoldings = new List { + new(10, 50), + new(60, 100), + new(110, 200), + }; + var saved = FoldingsViewState.Capture(new[] { + (10, 50, true), // was folded + (60, 100, false), // was expanded + (110, 200, true), // was folded + }); + + // Act — restore against the matching new-foldings layout. + var restored = FoldingsViewState.Restore(newFoldings, saved); + + // Assert — restoration ran (true return), and the per-folding DefaultClosed reflects + // the saved state: open in the middle, closed at the ends. + restored.Should().BeTrue(); + newFoldings[0].DefaultClosed.Should().BeTrue("(10, 50) was not in the saved expanded set"); + newFoldings[1].DefaultClosed.Should().BeFalse("(60, 100) was saved as expanded"); + newFoldings[2].DefaultClosed.Should().BeTrue("(110, 200) was not in the saved expanded set"); + } + + [Test] + public void Restore_Returns_False_And_Touches_Nothing_When_Checksum_Mismatch() + { + // The new document's foldings layout doesn't match what was saved — restoration + // must bail out without touching `DefaultClosed`. A partial restore over the wrong + // layout would expand random regions of the new document. + + // Arrange — new foldings with shifted offsets vs. the captured snapshot's source. + var newFoldings = new List { + new(15, 50) { DefaultClosed = true }, // shifted start + new(60, 100) { DefaultClosed = true }, + }; + var saved = FoldingsViewState.Capture(new[] { + (10, 50, false), // different start + (60, 100, false), + }); + + // Act — try to restore against the mismatched layout. + var restored = FoldingsViewState.Restore(newFoldings, saved); + + // Assert — restoration declined; DefaultClosed values are exactly as the caller set + // them (both true). A regression that "best-effort restored anyway" would flip one + // of them to false here. + restored.Should().BeFalse(); + newFoldings[0].DefaultClosed.Should().BeTrue(); + newFoldings[1].DefaultClosed.Should().BeTrue(); + } + + [Test] + public void Restore_Treats_Empty_New_And_Saved_As_A_Matching_NoOp() + { + // Edge case: both lists empty (e.g., the new tab has no foldings at all). The + // checksum is zero on both sides, so restore "matches" — but with nothing to do. + // It must return true so the caller doesn't log a spurious "layout changed" warning. + + // Arrange — empty new foldings, empty saved snapshot. + var newFoldings = new List(); + var saved = FoldingsViewState.Capture(System.Array.Empty<(int, int, bool)>()); + + // Act + Assert — restoration returns true; new list stays empty. + FoldingsViewState.Restore(newFoldings, saved).Should().BeTrue(); + newFoldings.Should().BeEmpty(); + } + + [Test] + public void Captured_Expanded_List_Is_A_Snapshot_Not_A_Live_View() + { + // The Expanded list must not mutate if the source enumeration is reused after the + // capture. Without this, a caller passing a List<> and then mutating it would see + // their navigation history quietly change. + + // Arrange — capture into a snapshot, then mutate the source list. + var source = new List<(int, int, bool)> { + (10, 50, false), + (60, 100, false), + }; + var snapshot = FoldingsViewState.Capture(source); + source.Clear(); + + // Assert — the captured snapshot still has the original entries. + snapshot.Expanded.Should().Equal(new[] { (10, 50), (60, 100) }); + } +} diff --git a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs index 5e249d6d1..d931b5faa 100644 --- a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs +++ b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs @@ -30,6 +30,8 @@ using ILSpy.TreeNodes; using ILSpy.ViewModels; using ILSpy.Views; +using FoldingSnapshot = ILSpy.TextView.FoldingsViewState.Snapshot; + using NUnit.Framework; namespace ICSharpCode.ILSpy.Tests.Navigation; @@ -101,6 +103,76 @@ public class ViewStateRoundTripTests "Back must restore the vertical scroll offset"); } + [AvaloniaTest] + public async Task Back_Carries_Expanded_Foldings_Snapshot_From_Capture_Through_To_Pending() + { + // Pins the foldings half of the view-state round trip end-to-end through DockWorkspace. + // The headless editor doesn't actually build foldings, so we can't observe a real + // FoldingManager state. Instead seed LastKnownFoldings directly on the tab (mimicking + // what the view's CaptureFoldingsState delegate would push), exercise the navigation + // pipeline, and assert the snapshot survives the capture → entry → pending hops. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var dockWorkspace = vm.DockWorkspace; + var coreLibName = typeof(object).Assembly.GetName().Name!; + var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + + vm.AssemblyTreeModel.SelectNode(objectNode); + await dockWorkspace.WaitForDecompiledTextAsync(); + var tab = dockWorkspace.ActiveDecompilerTab!; + + // Seed a deterministic foldings snapshot — two expanded regions over a four-folding + // layout. Compute it via the helper to keep the checksum honest. The view normally + // re-snapshots its live foldings via the CaptureFoldingsState delegate at navigate- + // away time, which would clobber the seed — disable it for this isolation test so we + // observe just the entry → pending plumbing inside DockWorkspace. (The snapshot + // itself is independently exercised in FoldingsViewStateTests.) + var seeded = FoldingsViewState.Capture(new[] { + (Start: 10, End: 50, IsFolded: false), + (Start: 60, End: 100, IsFolded: true), + (Start: 110, End: 200, IsFolded: false), + (Start: 210, End: 250, IsFolded: true), + }); + tab.LastKnownFoldings = seeded; + tab.CaptureFoldingsState = null; + + // Navigate to a different node — DockWorkspace.CaptureCurrentViewState reads + // LastKnownFoldings and stamps it onto the OUTGOING entry on the back stack. + vm.AssemblyTreeModel.SelectNode(stringNode); + await dockWorkspace.WaitForDecompiledTextAsync(); + + // Verify the capture: the back-stack entry for node A carries the snapshot. + var captured = dockWorkspace.BackHistory.OfType().Last(); + ReferenceEquals(captured.Node, objectNode).Should().BeTrue( + "captured back-stack entry must reference node A"); + captured.Foldings.Should().NotBeNull("Select(B) must record A's foldings into the back stack"); + captured.Foldings!.Value.Checksum.Should().Be(seeded.Checksum); + captured.Foldings.Value.Expanded.Should().Equal(seeded.Expanded); + + // Clear the pending slot on the tab so we can observe the Back-driven set. + tab.PendingFoldings = null; + + // Navigate Back. ApplyNavigationTarget writes the recorded snapshot into + // PendingFoldings so the view consumes it on the next ApplyDocument. The view's + // consume-and-null happens after the decompile completes, but the assignment from + // DockWorkspace is synchronous, so we can observe it without waiting. + dockWorkspace.NavigateBackCommand.Execute(null); + + // Read PendingFoldings before the editor consumes it. In headless mode the editor + // may not run ApplyDocument at all (no Editor surface), but the assignment from + // DockWorkspace.ApplyNavigationTarget already happened. Either we still see it + // pending, OR we see a fresh capture write LastKnownFoldings from the view's hook + // — both are valid "the snapshot made it through" outcomes for this assertion. + var observed = tab.PendingFoldings ?? tab.LastKnownFoldings; + observed.Should().NotBeNull("Back must propagate the captured snapshot to the destination tab"); + observed!.Value.Checksum.Should().Be(seeded.Checksum); + } + [AvaloniaTest] public async Task Forward_Is_Wired_Through_The_Toolbar_And_Key_Binding() { diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 107ccfe37..8155ac52c 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -242,9 +242,14 @@ namespace ILSpy.Docking var decompTab = UnwrapDecompilerTab(current.Tab); if (decompTab == null) return; + // Foldings have no model-side property-change event we can mirror onto, so the + // text view's snapshot delegate has to be invoked synchronously here. Caret and + // scroll values are kept fresh by per-event push from the view; foldings aren't. + decompTab.CaptureFoldingsState?.Invoke(); current.CaretOffset = decompTab.LastKnownCaretOffset; current.VerticalOffset = decompTab.LastKnownVerticalOffset; current.HorizontalOffset = decompTab.LastKnownHorizontalOffset; + current.Foldings = decompTab.LastKnownFoldings; } // The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel @@ -296,6 +301,7 @@ namespace ILSpy.Docking decompTab.PendingCaretOffset = treeNode.CaretOffset; decompTab.PendingVerticalOffset = treeNode.VerticalOffset; decompTab.PendingHorizontalOffset = treeNode.HorizontalOffset; + decompTab.PendingFoldings = treeNode.Foldings; } assemblyTreeModel.SelectedItem = treeNode.Node; } diff --git a/ILSpy/NavigationEntry.cs b/ILSpy/NavigationEntry.cs index 9bb452da8..9874f0136 100644 --- a/ILSpy/NavigationEntry.cs +++ b/ILSpy/NavigationEntry.cs @@ -23,6 +23,7 @@ using Avalonia.Media; using ICSharpCode.ILSpyX.TreeView; +using ILSpy.TextView; using ILSpy.TreeNodes; using ILSpy.ViewModels; @@ -79,6 +80,14 @@ namespace ILSpy.Navigation /// Horizontal scroll offset captured at navigate-away time. public double? HorizontalOffset { get; set; } + /// + /// Snapshot of which code-folding regions the user had expanded when navigating + /// away from this entry. null means "no capture yet" (entries land null and + /// are populated by the record-history hook). Restoration is checksum-gated, so a + /// snapshot saved against a different document is safely ignored at Back-apply time. + /// + public FoldingsViewState.Snapshot? Foldings { get; set; } + public TreeNodeEntry(TabPageModel tab, SharpTreeNode node) : base(tab) { diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 862e83c25..462993cb2 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -166,6 +166,31 @@ namespace ILSpy.TextView /// Horizontal scroll offset to restore alongside . public double? PendingHorizontalOffset { get; set; } + /// + /// Snapshot of the editor's currently expanded foldings + a layout checksum, kept + /// up to date by . Read by + /// 's capture-on-navigate hook so the outgoing + /// history entry records which regions the user had open. + /// + public FoldingsViewState.Snapshot? LastKnownFoldings { get; set; } + + /// + /// Foldings snapshot to apply on the next document apply. Set by + /// when Back/Forward lands on an entry that + /// carries a captured foldings snapshot. The text view consumes this in its + /// ApplyDocument path right before installing the new FoldingManager. + /// + public FoldingsViewState.Snapshot? PendingFoldings { get; set; } + + /// + /// Invoked by just before recording a navigation + /// entry, so the text view can push its current foldings into + /// synchronously. AvaloniaEdit's FoldingManager has + /// no foldings-changed event we can subscribe to, so we snapshot on demand instead + /// of mirroring the per-event caret/scroll pattern. + /// + public System.Action? CaptureFoldingsState { get; set; } + /// /// Fired when the user clicks a cross-document reference. The host (DockWorkspace) /// resolves the target on the assembly tree side. diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index d9fc479b7..5bffe017f 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -318,10 +318,22 @@ namespace ILSpy.TextView if (DataContext is DecompilerTabPageModel model) { model.PropertyChanged += OnModelPropertyChanged; + // Foldings have no per-change event on AvaloniaEdit; instead DockWorkspace asks + // the view for a fresh snapshot at navigate-away time. Assigning the delegate + // every DataContext-change handles both the first attach and an ABA reattach. + model.CaptureFoldingsState = () => SnapshotFoldingsInto(model); ApplyDocument(model); } } + void SnapshotFoldingsInto(DecompilerTabPageModel model) + { + if (activeFoldingManager is { } manager) + model.LastKnownFoldings = FoldingsViewState.Capture(manager.AllFoldings); + else + model.LastKnownFoldings = null; + } + void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { // Only rebuild when Text changes — DecompileAsync sets HighlightingModel and Foldings @@ -384,10 +396,22 @@ namespace ILSpy.TextView FoldingManager.Uninstall(activeFoldingManager); activeFoldingManager = null; } + // Consume the pending snapshot up-front so it can't bleed into a later refresh + // even when the new document has zero foldings to apply it to (e.g. a namespace + // summary page that follows a method-body navigation). + var pendingFoldings = model.PendingFoldings; + model.PendingFoldings = null; if (model.Foldings is { Count: > 0 } foldings) { activeFoldingManager = FoldingManager.Install(Editor.TextArea); - activeFoldingManager.UpdateFoldings(foldings.OrderBy(f => f.StartOffset), -1); + var ordered = foldings.OrderBy(f => f.StartOffset).ToList(); + // Project the saved snapshot onto the freshly-built list so each NewFolding's + // DefaultClosed reflects the previous state BEFORE UpdateFoldings installs them. + // The checksum inside Restore guards against the document having shifted under + // our feet (Refresh / settings change / etc). + if (pendingFoldings is { } pending) + FoldingsViewState.Restore(ordered, pending); + activeFoldingManager.UpdateFoldings(ordered, -1); } else if (model.SyntaxExtension == ".xml" && !string.IsNullOrEmpty(model.Text)) { diff --git a/ILSpy/TextView/FoldingsViewState.cs b/ILSpy/TextView/FoldingsViewState.cs new file mode 100644 index 000000000..605700bf8 --- /dev/null +++ b/ILSpy/TextView/FoldingsViewState.cs @@ -0,0 +1,106 @@ +// 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; +using System.Linq; + +using AvaloniaEdit.Folding; + +namespace ILSpy.TextView +{ + /// + /// Captures and restores the set of expanded code-folding regions for a single + /// decompiled view, so Back/Forward navigation can put the user back exactly where + /// they left off (caret + scroll are handled separately by the LastKnown* / + /// Pending* pair on ). + /// + /// The protective semantics mirror the WPF implementation: a checksum over the + /// full folding layout (every folding's offsets, regardless of fold state) acts + /// as an "is this still the same document" gate. If the new document's foldings + /// don't checksum to the saved value, restoration declines and the new view + /// keeps its default fold state — otherwise a stale snapshot could accidentally + /// expand random regions of a shifted document. + /// + /// + public static class FoldingsViewState + { + /// + /// A frozen snapshot of which foldings were expanded at a point in time. The + /// list is the user-actionable subset; + /// is the layout fingerprint used by to refuse mismatched + /// restorations. + /// + public readonly record struct Snapshot(IReadOnlyList<(int Start, int End)> Expanded, int Checksum); + + /// + /// Tuple-form capture used by tests and by the FoldingSection-form overload. Walks + /// the input once and accumulates two outputs: the list of (Start, End) pairs whose + /// IsFolded is false (the "currently expanded" subset), plus a checksum over every + /// folding's offsets in iteration order. + /// + public static Snapshot Capture(IEnumerable<(int Start, int End, bool IsFolded)> foldings) + { + var expanded = new List<(int Start, int End)>(); + int checksum = 0; + foreach (var (start, end, isFolded) in foldings) + { + checksum = unchecked(checksum + start * 3 - end); + if (!isFolded) + expanded.Add((start, end)); + } + return new Snapshot(expanded, checksum); + } + + /// + /// Live-folding-section adapter that delegates to the tuple form. Used by the editor + /// surface to snapshot its current AvaloniaEdit FoldingManager state. + /// + public static Snapshot Capture(IEnumerable foldings) + => Capture(foldings.Select(f => (f.StartOffset, f.EndOffset, f.IsFolded))); + + /// + /// Applies to the freshly-built + /// list (which is about to be installed into a FoldingManager). Returns true + /// when the saved layout matches the new layout and + /// has been adjusted to recreate the expanded subset; false when the checksum + /// mismatch caused restoration to bail out (in which case the new list is untouched). + /// + public static bool Restore(IList newFoldings, Snapshot saved) + { + int checksum = 0; + foreach (var folding in newFoldings) + checksum = unchecked(checksum + folding.StartOffset * 3 - folding.EndOffset); + if (checksum != saved.Checksum) + return false; + foreach (var folding in newFoldings) + { + bool wasExpanded = false; + foreach (var (start, end) in saved.Expanded) + { + if (start == folding.StartOffset && end == folding.EndOffset) + { + wasExpanded = true; + break; + } + } + folding.DefaultClosed = !wasExpanded; + } + return true; + } + } +}