Browse Source

ViewState — Back/Forward also restores expanded foldings

Foldings persistence rounds out the view-state work that landed in 185e0551d
(caret + scroll). Mirrors WPF's DecompilerTextViewState.SaveFoldingsState /
RestoreFoldings semantics verbatim — including the layout-checksum gate that
refuses restoration when the new document's foldings don't match the captured
layout, so a stale snapshot can't accidentally expand random regions of a
shifted document.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
b6721b5bc4
  1. 196
      ILSpy.Tests/Editor/FoldingsViewStateTests.cs
  2. 72
      ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs
  3. 6
      ILSpy/Docking/DockWorkspace.cs
  4. 9
      ILSpy/NavigationEntry.cs
  5. 25
      ILSpy/TextView/DecompilerTabPageModel.cs
  6. 26
      ILSpy/TextView/DecompilerTextView.axaml.cs
  7. 106
      ILSpy/TextView/FoldingsViewState.cs

196
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;
/// <summary>
/// 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
/// <c>DecompilerTextViewState.SaveFoldingsState</c> / <c>RestoreFoldings</c> so that
/// the protective "skip on layout mismatch" semantics carry over identically.
/// </summary>
[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<NewFolding> {
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<NewFolding> {
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<NewFolding>();
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) });
}
}

72
ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs

@ -30,6 +30,8 @@ using ILSpy.TreeNodes;
using ILSpy.ViewModels; using ILSpy.ViewModels;
using ILSpy.Views; using ILSpy.Views;
using FoldingSnapshot = ILSpy.TextView.FoldingsViewState.Snapshot;
using NUnit.Framework; using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Navigation; namespace ICSharpCode.ILSpy.Tests.Navigation;
@ -101,6 +103,76 @@ public class ViewStateRoundTripTests
"Back must restore the vertical scroll offset"); "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<MainWindow>();
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<TypeTreeNode>(coreLibName, "System", "System.Object");
var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(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<TreeNodeEntry>().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] [AvaloniaTest]
public async Task Forward_Is_Wired_Through_The_Toolbar_And_Key_Binding() public async Task Forward_Is_Wired_Through_The_Toolbar_And_Key_Binding()
{ {

6
ILSpy/Docking/DockWorkspace.cs

@ -242,9 +242,14 @@ namespace ILSpy.Docking
var decompTab = UnwrapDecompilerTab(current.Tab); var decompTab = UnwrapDecompilerTab(current.Tab);
if (decompTab == null) if (decompTab == null)
return; 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.CaretOffset = decompTab.LastKnownCaretOffset;
current.VerticalOffset = decompTab.LastKnownVerticalOffset; current.VerticalOffset = decompTab.LastKnownVerticalOffset;
current.HorizontalOffset = decompTab.LastKnownHorizontalOffset; current.HorizontalOffset = decompTab.LastKnownHorizontalOffset;
current.Foldings = decompTab.LastKnownFoldings;
} }
// The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel // The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel
@ -296,6 +301,7 @@ namespace ILSpy.Docking
decompTab.PendingCaretOffset = treeNode.CaretOffset; decompTab.PendingCaretOffset = treeNode.CaretOffset;
decompTab.PendingVerticalOffset = treeNode.VerticalOffset; decompTab.PendingVerticalOffset = treeNode.VerticalOffset;
decompTab.PendingHorizontalOffset = treeNode.HorizontalOffset; decompTab.PendingHorizontalOffset = treeNode.HorizontalOffset;
decompTab.PendingFoldings = treeNode.Foldings;
} }
assemblyTreeModel.SelectedItem = treeNode.Node; assemblyTreeModel.SelectedItem = treeNode.Node;
} }

9
ILSpy/NavigationEntry.cs

@ -23,6 +23,7 @@ using Avalonia.Media;
using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpyX.TreeView;
using ILSpy.TextView;
using ILSpy.TreeNodes; using ILSpy.TreeNodes;
using ILSpy.ViewModels; using ILSpy.ViewModels;
@ -79,6 +80,14 @@ namespace ILSpy.Navigation
/// <summary>Horizontal scroll offset captured at navigate-away time.</summary> /// <summary>Horizontal scroll offset captured at navigate-away time.</summary>
public double? HorizontalOffset { get; set; } public double? HorizontalOffset { get; set; }
/// <summary>
/// Snapshot of which code-folding regions the user had expanded when navigating
/// away from this entry. <c>null</c> 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.
/// </summary>
public FoldingsViewState.Snapshot? Foldings { get; set; }
public TreeNodeEntry(TabPageModel tab, SharpTreeNode node) public TreeNodeEntry(TabPageModel tab, SharpTreeNode node)
: base(tab) : base(tab)
{ {

25
ILSpy/TextView/DecompilerTabPageModel.cs

@ -166,6 +166,31 @@ namespace ILSpy.TextView
/// <summary>Horizontal scroll offset to restore alongside <see cref="PendingCaretOffset"/>.</summary> /// <summary>Horizontal scroll offset to restore alongside <see cref="PendingCaretOffset"/>.</summary>
public double? PendingHorizontalOffset { get; set; } public double? PendingHorizontalOffset { get; set; }
/// <summary>
/// Snapshot of the editor's currently expanded foldings + a layout checksum, kept
/// up to date by <see cref="CaptureFoldingsState"/>. Read by
/// <see cref="Docking.DockWorkspace"/>'s capture-on-navigate hook so the outgoing
/// history entry records which regions the user had open.
/// </summary>
public FoldingsViewState.Snapshot? LastKnownFoldings { get; set; }
/// <summary>
/// Foldings snapshot to apply on the next document apply. Set by
/// <see cref="Docking.DockWorkspace"/> 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.
/// </summary>
public FoldingsViewState.Snapshot? PendingFoldings { get; set; }
/// <summary>
/// Invoked by <see cref="Docking.DockWorkspace"/> just before recording a navigation
/// entry, so the text view can push its current foldings into
/// <see cref="LastKnownFoldings"/> 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.
/// </summary>
public System.Action? CaptureFoldingsState { get; set; }
/// <summary> /// <summary>
/// Fired when the user clicks a cross-document reference. The host (DockWorkspace) /// Fired when the user clicks a cross-document reference. The host (DockWorkspace)
/// resolves the target on the assembly tree side. /// resolves the target on the assembly tree side.

26
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -318,10 +318,22 @@ namespace ILSpy.TextView
if (DataContext is DecompilerTabPageModel model) if (DataContext is DecompilerTabPageModel model)
{ {
model.PropertyChanged += OnModelPropertyChanged; 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); 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) void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
// Only rebuild when Text changes — DecompileAsync sets HighlightingModel and Foldings // Only rebuild when Text changes — DecompileAsync sets HighlightingModel and Foldings
@ -384,10 +396,22 @@ namespace ILSpy.TextView
FoldingManager.Uninstall(activeFoldingManager); FoldingManager.Uninstall(activeFoldingManager);
activeFoldingManager = null; 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) if (model.Foldings is { Count: > 0 } foldings)
{ {
activeFoldingManager = FoldingManager.Install(Editor.TextArea); 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)) else if (model.SyntaxExtension == ".xml" && !string.IsNullOrEmpty(model.Text))
{ {

106
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
{
/// <summary>
/// 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 <see cref="DecompilerTabPageModel"/>).
/// <para>
/// 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.
/// </para>
/// </summary>
public static class FoldingsViewState
{
/// <summary>
/// A frozen snapshot of which foldings were expanded at a point in time. The
/// <see cref="Expanded"/> list is the user-actionable subset; <see cref="Checksum"/>
/// is the layout fingerprint used by <see cref="Restore"/> to refuse mismatched
/// restorations.
/// </summary>
public readonly record struct Snapshot(IReadOnlyList<(int Start, int End)> Expanded, int Checksum);
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Live-folding-section adapter that delegates to the tuple form. Used by the editor
/// surface to snapshot its current AvaloniaEdit FoldingManager state.
/// </summary>
public static Snapshot Capture(IEnumerable<FoldingSection> foldings)
=> Capture(foldings.Select(f => (f.StartOffset, f.EndOffset, f.IsFolded)));
/// <summary>
/// Applies <paramref name="saved"/> to the freshly-built <paramref name="newFoldings"/>
/// list (which is about to be installed into a FoldingManager). Returns <c>true</c>
/// when the saved layout matches the new layout and <see cref="NewFolding.DefaultClosed"/>
/// has been adjusted to recreate the expanded subset; <c>false</c> when the checksum
/// mismatch caused restoration to bail out (in which case the new list is untouched).
/// </summary>
public static bool Restore(IList<NewFolding> 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;
}
}
}
Loading…
Cancel
Save