Browse Source

Restore decompiler view-state by pulling it on navigation capture

Capture the editor's caret, scroll offset, and expanded foldings on
demand when a navigation record is written -- the model exposes a
CaptureViewState delegate the view sets and DockWorkspace invokes --
instead of pushing every caret/scroll event onto the view-model. The
push mistook AvaloniaEdit's programmatic caret-to-end on text replace for
a user move and recorded it, poisoning the captured position. Back /
Forward restores through a single PendingViewState channel the editor
consumes once the document Text lands.

AvaloniaEdit 12.0.0's ScrollToVerticalOffset is a no-op, so the editor's
ScrollViewer.Offset is set directly (TODO: drop once AvaloniaEdit #594
ships and the package is bumped).

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3755/head
Siegfried Pammer 1 month ago
parent
commit
fc930901df
  1. 90
      ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs
  2. 28
      ILSpy/Docking/DockWorkspace.cs
  3. 62
      ILSpy/TextView/DecompilerTabPageModel.cs
  4. 153
      ILSpy/TextView/DecompilerTextView.axaml.cs
  5. 38
      ILSpy/TextView/DecompilerTextViewState.cs

90
ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs

@ -64,21 +64,18 @@ public class ViewStateRoundTripTests @@ -64,21 +64,18 @@ public class ViewStateRoundTripTests
await dockWorkspace.WaitForDecompiledTextAsync();
var tab = dockWorkspace.ActiveDecompilerTab!;
// Simulate the user moving the caret on node A. The view's PositionChanged handler
// is wired to push LastKnownCaretOffset onto the model, but headless tests don't
// fire that event by simulating user clicks — write the LastKnown values directly
// to model the post-interaction state.
tab.LastKnownCaretOffset = 500;
tab.LastKnownVerticalOffset = 120.5;
tab.LastKnownHorizontalOffset = 7.25;
// Navigate to a different node — captures A's state into the history's current
// State is pulled from the editor on demand (CaptureViewState) when DockWorkspace records
// a navigation away -- not pushed per caret/scroll event. Headless has no laid-out editor,
// so override the pull delegate to report the position the user "left" on node A.
tab.CaptureViewState = () => new DecompilerTextViewState(500, 120.5, 7.25, null);
// Navigate to a different node — DockWorkspace pulls A's state into the current history
// entry and records B as the new current.
vm.AssemblyTreeModel.SelectNode(stringNode);
await dockWorkspace.WaitForDecompiledTextAsync();
// Verify the capture: the back stack's most recent entry should be A's, with
// the caret + scroll values we set.
// Verify the capture: the back stack's most recent entry should be A's, with the pulled
// caret + scroll values.
var backEntries = dockWorkspace.BackHistory.OfType<TreeNodeEntry>().ToList();
backEntries.Should().NotBeEmpty("Select(B) must push A onto the back stack");
var captured = backEntries.Last();
@ -88,34 +85,31 @@ public class ViewStateRoundTripTests @@ -88,34 +85,31 @@ public class ViewStateRoundTripTests
captured.VerticalOffset.Should().Be(120.5);
captured.HorizontalOffset.Should().Be(7.25);
// Navigate Back. ApplyNavigationTarget synchronously sets PendingVerticalOffset
// from the recorded entry before the async decompile runs; the editor view
// consumes it in ApplyDocument once Text lands. Assert the scroll-restore intent
// here, before the await lets the view consume and null it. We can't assert the
// applied result: the headless editor has no rendered viewport (extent 0), so
// ScrollToVerticalOffset(120.5) clamps to 0 and LastKnownVerticalOffset reads back
// 0. Applying the scroll is the view's job, covered by the real UI, not this test.
// Navigate Back. ApplyNavigationTarget synchronously stashes the recorded state on the tab
// as PendingViewState before the async decompile runs; the editor consumes it in
// ApplyDocument once Text lands. Assert the restore intent here, before the await lets the
// view consume it -- applying caret/scroll to the live editor is the view's job (covered by
// the real UI; headless has no rendered viewport).
dockWorkspace.NavigateBackCommand.Execute(null);
tab.PendingVerticalOffset.Should().Be(120.5,
"Back must schedule a restore of the vertical scroll offset the user left on A");
tab.PendingViewState.Should().NotBeNull("Back must stash the recorded view state for the editor to apply");
tab.PendingViewState!.Value.CaretOffset.Should().Be(500,
"Back must restore the caret to where the user left it before navigating to B");
tab.PendingViewState!.Value.VerticalOffset.Should().Be(120.5,
"Back must restore the vertical scroll offset the user left on A");
await dockWorkspace.WaitForDecompiledTextAsync();
// The caret is a document position, so it round-trips even headless: the view's
// caret-change handler writes LastKnownCaretOffset when it programmatically moves
// the caret in ApplyDocument.
tab.LastKnownCaretOffset.Should().Be(500,
"Back must restore the caret to where the user left it before navigating to B");
// The editor consumes and clears PendingViewState once the document lands.
tab.PendingViewState.Should().BeNull("the editor must consume the pending state after applying it");
}
[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.
// The headless editor doesn't build real foldings, so override the pull delegate to report
// a deterministic snapshot, exercise the navigation pipeline, and assert the snapshot
// survives the capture -> entry -> pending hops.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
@ -131,23 +125,19 @@ public class ViewStateRoundTripTests @@ -131,23 +125,19 @@ public class ViewStateRoundTripTests
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.)
// Deterministic foldings snapshot — two expanded regions over a four-folding layout.
// Compute via the helper to keep the checksum honest. (The snapshot itself is exercised
// independently 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;
tab.CaptureViewState = () => new DecompilerTextViewState(0, 0, 0, seeded);
// Navigate to a different node — DockWorkspace.CaptureCurrentViewState reads
// LastKnownFoldings and stamps it onto the OUTGOING entry on the back stack.
// Navigate to a different node — DockWorkspace pulls the state and stamps the foldings
// onto the OUTGOING entry on the back stack.
vm.AssemblyTreeModel.SelectNode(stringNode);
await dockWorkspace.WaitForDecompiledTextAsync();
@ -159,23 +149,13 @@ public class ViewStateRoundTripTests @@ -159,23 +149,13 @@ public class ViewStateRoundTripTests
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.
// Navigate Back. ApplyNavigationTarget stashes the recorded snapshot into PendingViewState
// for the view to consume on the next ApplyDocument. The assignment is synchronous, so we
// can observe it before the await lets the editor consume it.
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);
tab.PendingViewState.Should().NotBeNull("Back must propagate the captured state to the destination tab");
tab.PendingViewState!.Value.Foldings.Should().NotBeNull();
tab.PendingViewState!.Value.Foldings!.Value.Checksum.Should().Be(seeded.Checksum);
}
[AvaloniaTest]

28
ILSpy/Docking/DockWorkspace.cs

@ -508,17 +508,16 @@ namespace ILSpy.Docking @@ -508,17 +508,16 @@ namespace ILSpy.Docking
{
if (history.Current is not TreeNodeEntry current)
return;
var decompTab = UnwrapDecompilerTab(current.Tab);
if (decompTab == null)
// Pull the editor's live view state (caret + scroll + foldings) on demand. Reading it
// only at this point -- when we record the navigation away -- avoids the poisoned
// captures a per-event push suffered (AvaloniaEdit jumps the caret to the end on a
// text replace). Null when no view is attached / nothing to capture.
if (UnwrapDecompilerTab(current.Tab)?.CaptureViewState?.Invoke() is not { } state)
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;
current.CaretOffset = state.CaretOffset;
current.VerticalOffset = state.VerticalOffset;
current.HorizontalOffset = state.HorizontalOffset;
current.Foldings = state.Foldings;
}
// The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel
@ -567,10 +566,11 @@ namespace ILSpy.Docking @@ -567,10 +566,11 @@ namespace ILSpy.Docking
// the Pending* fields right after Text lands and restores caret + scroll.
if (UnwrapDecompilerTab(treeNode.Tab) is { } decompTab)
{
decompTab.PendingCaretOffset = treeNode.CaretOffset;
decompTab.PendingVerticalOffset = treeNode.VerticalOffset;
decompTab.PendingHorizontalOffset = treeNode.HorizontalOffset;
decompTab.PendingFoldings = treeNode.Foldings;
decompTab.PendingViewState = new TextView.DecompilerTextViewState(
treeNode.CaretOffset ?? 0,
treeNode.VerticalOffset ?? 0,
treeNode.HorizontalOffset ?? 0,
treeNode.Foldings);
}
assemblyTreeModel.SelectedItem = treeNode.Node;
}

62
ILSpy/TextView/DecompilerTabPageModel.cs

@ -145,60 +145,22 @@ namespace ILSpy.TextView @@ -145,60 +145,22 @@ namespace ILSpy.TextView
private object? highlightedReference;
/// <summary>
/// Last caret offset the editor view reported. The text view writes this on every
/// caret-position change so <see cref="Docking.DockWorkspace"/> can read the current
/// position when recording a navigation away from this tab. Zero until the view
/// reports its first caret position; that's a safe default because a freshly-shown
/// document also has caret at zero.
/// Pulls the editor's current view state (caret, scroll, expanded foldings) on demand.
/// Set by the text view when it binds to this model; invoked by
/// <see cref="Docking.DockWorkspace"/> when it records a navigation away from this tab.
/// Null until a view is attached. Reading on demand -- rather than mirroring every caret /
/// scroll event onto the model -- avoids capturing the programmatic caret-to-end that
/// AvaloniaEdit performs when the document text is replaced.
/// </summary>
public int LastKnownCaretOffset { get; set; }
/// <summary>Last vertical scroll offset the editor view reported. See <see cref="LastKnownCaretOffset"/>.</summary>
public double LastKnownVerticalOffset { get; set; }
/// <summary>Last horizontal scroll offset the editor view reported. See <see cref="LastKnownCaretOffset"/>.</summary>
public double LastKnownHorizontalOffset { get; set; }
/// <summary>
/// Caret offset the editor view should restore on the next document apply. Set when
/// Back/Forward navigation lands on an entry that carries a captured caret position
/// (see <see cref="Navigation.TreeNodeEntry.CaretOffset"/>); cleared by the view
/// after it reads the value. The pattern mirrors <see cref="HighlightedReference"/> —
/// the editor consumes this in its <c>ApplyDocument</c> path so the value survives
/// the async gap between Back firing and the decompile finishing.
/// </summary>
public int? PendingCaretOffset { get; set; }
/// <summary>Vertical scroll offset to restore alongside <see cref="PendingCaretOffset"/>.</summary>
public double? PendingVerticalOffset { get; set; }
/// <summary>Horizontal scroll offset to restore alongside <see cref="PendingCaretOffset"/>.</summary>
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; }
public System.Func<DecompilerTextViewState>? CaptureViewState { 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.
/// View state to restore on the next document apply. Set by <see cref="Docking.DockWorkspace"/>
/// when Back/Forward/GoTo lands on an entry that carries a captured state; consumed (and
/// cleared) by the text view in its <c>ApplyDocument</c> path so the value survives the
/// async gap between the navigation firing and the decompile finishing.
/// </summary>
public System.Action? CaptureFoldingsState { get; set; }
public DecompilerTextViewState? PendingViewState { get; set; }
/// <summary>
/// Fired when the user clicks a cross-document reference. The host (DockWorkspace)

153
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -31,6 +31,7 @@ using Avalonia.Input; @@ -31,6 +31,7 @@ using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit.Folding;
using AvaloniaEdit.Highlighting;
@ -78,6 +79,11 @@ namespace ILSpy.TextView @@ -78,6 +79,11 @@ namespace ILSpy.TextView
// the previous DataContext to the event handler, and Dock recycles the view across
// tabs (see App.axaml ControlRecyclingKey).
DecompilerTabPageModel? boundModel;
// The editor's template ScrollViewer. AvaloniaEdit's TextEditor.ScrollViewer is internal,
// and its ScrollToVerticalOffset/ScrollToHorizontalOffset are no-ops in 12.0.0
// (AvaloniaUI/AvaloniaEdit#594) -- so we reach the ScrollViewer ourselves and set Offset.
// TODO: drop this field and use Editor.ScrollToVerticalOffset once #594 ships and we bump the package.
ScrollViewer? editorScrollViewer;
DisplaySettings? currentDisplaySettings;
ReferenceSegment? lastTooltipSegment;
ReferenceSegment? lastRightClickedSegment;
@ -179,12 +185,12 @@ namespace ILSpy.TextView @@ -179,12 +185,12 @@ namespace ILSpy.TextView
WireUpDisplaySettings();
// Push caret + scroll positions onto the bound tab model on every change so
// DockWorkspace can read the live state when recording a navigation away. Cheap
// — both events fire only on actual user motion and the model write is two
// integer / double assignments.
// Caret-position changes drive the bracket-pair highlight. View state (caret + scroll
// + foldings) is NOT pushed onto the model here -- DockWorkspace pulls it on demand via
// the model's CaptureViewState delegate when it records a navigation away, so a
// programmatic caret move (AvaloniaEdit jumps the caret to the end on a text replace)
// is never mistaken for the user's position.
Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged;
Editor.TextArea.TextView.ScrollOffsetChanged += OnScrollOffsetChanged;
// Ctrl+Wheel zoom (matches WPF's ZoomScrollViewer.OnPreviewMouseWheel). Tunnel
// routing so we see it before AvaloniaEdit's scroll handler; Handled=true on
@ -283,8 +289,6 @@ namespace ILSpy.TextView @@ -283,8 +289,6 @@ namespace ILSpy.TextView
void OnCaretPositionChanged(object? sender, EventArgs e)
{
if (DataContext is DecompilerTabPageModel m)
m.LastKnownCaretOffset = Editor.TextArea.Caret.Offset;
UpdateBracketHighlight();
}
@ -303,15 +307,6 @@ namespace ILSpy.TextView @@ -303,15 +307,6 @@ namespace ILSpy.TextView
bracketHighlightRenderer.SetHighlight(result);
}
void OnScrollOffsetChanged(object? sender, EventArgs e)
{
if (DataContext is DecompilerTabPageModel m)
{
m.LastKnownVerticalOffset = Editor.VerticalOffset;
m.LastKnownHorizontalOffset = Editor.HorizontalOffset;
}
}
/// <summary>
/// Mirrors the live <see cref="DisplaySettings"/> into the editor on construction and on
/// every <c>PropertyChanged</c> after that. Lets the Options Display panel produce
@ -440,46 +435,40 @@ namespace ILSpy.TextView @@ -440,46 +435,40 @@ namespace ILSpy.TextView
{
base.OnDataContextChanged(e);
// Snapshot the outgoing model's folding state and detach its property handler
// BEFORE binding to the new one. Dock recycles a single DecompilerTextView across
// every document tab, so a tab switch lands here with the FoldingManager still
// holding the previous tab's collapsed / expanded state. Capture that into the
// outgoing model's LastKnownFoldings so the next ApplyDocument can restore it
// when the user clicks back. Without this the foldings reset to their default
// (open) state on every tab toggle.
// Detach the outgoing model's handlers before binding to the new one. Avalonia doesn't
// surface the previous DataContext to this event, so we track it ourselves.
if (boundModel is { } previous && !ReferenceEquals(previous, DataContext))
{
previous.PropertyChanged -= OnModelPropertyChanged;
if (activeFoldingManager is { } outgoingManager)
previous.LastKnownFoldings = FoldingsViewState.Capture(outgoingManager.AllFoldings);
// The delegate closes over `activeFoldingManager` (the view field), not the
// model's own state. Once we've switched to a new model the field is about
// to point at someone else's FoldingManager, so a late invocation from
// DockWorkspace.CaptureCurrentViewState would write THIS tab's foldings into
// the outgoing model's LastKnownFoldings. Drop the delegate so that path is
// a no-op; the snapshot we just took is correct and shouldn't be clobbered.
previous.CaptureFoldingsState = null;
previous.CaptureViewState = null;
}
boundModel = DataContext as DecompilerTabPageModel;
if (boundModel is { } 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);
// Let DockWorkspace pull the live view state from this editor on demand when it
// records a navigation away. (Re)assigning every DataContext-change handles both
// the first attach and an ABA reattach.
model.CaptureViewState = GetCurrentViewState;
ApplyDocument(model);
}
}
void SnapshotFoldingsInto(DecompilerTabPageModel model)
{
if (activeFoldingManager is { } manager)
model.LastKnownFoldings = FoldingsViewState.Capture(manager.AllFoldings);
else
model.LastKnownFoldings = null;
}
// Reads the editor's live caret + scroll + expanded-foldings state. Invoked by
// DockWorkspace via DecompilerTabPageModel.CaptureViewState when it writes a navigation
// record, so the captured position reflects exactly what the user is looking at now.
DecompilerTextViewState GetCurrentViewState() => new(
Editor.TextArea.Caret.Offset,
Editor.VerticalOffset,
Editor.HorizontalOffset,
activeFoldingManager is { } manager ? FoldingsViewState.Capture(manager.AllFoldings) : null);
// The editor's template ScrollViewer (cached once found). ?? re-evaluates while null so it
// resolves once the template is applied.
ScrollViewer? EditorScrollViewer =>
editorScrollViewer ??= Editor.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
@ -491,7 +480,9 @@ namespace ILSpy.TextView @@ -491,7 +480,9 @@ namespace ILSpy.TextView
&& (e.PropertyName == nameof(DecompilerTabPageModel.Text)
|| e.PropertyName == nameof(DecompilerTabPageModel.SyntaxExtension)))
{
ApplyDocument(model);
// Restore caret/scroll/foldings only on the Text change (the final content);
// SyntaxExtension fires an intermediate rebuild that must not move the scroll.
ApplyDocument(model, restoreViewState: e.PropertyName == nameof(DecompilerTabPageModel.Text));
}
// HighlightedReference can change independently of a re-decompile (e.g. the analyzer
// pane sets it while the user is already on the right tab). Apply it directly without
@ -513,7 +504,11 @@ namespace ILSpy.TextView @@ -513,7 +504,11 @@ namespace ILSpy.TextView
HighlightLocalReferences(model, model.HighlightedReference);
}
void ApplyDocument(DecompilerTabPageModel model)
// restoreViewState: only the Text change carries the final decompiled content, so only it
// should consume PendingViewState and restore (or reset) caret/scroll/foldings. The
// intermediate SyntaxExtension change rebuilds the display but must NOT touch the scroll,
// or it resets to the top before the Text pass restores -- a visible flicker on Back.
void ApplyDocument(DecompilerTabPageModel model, bool restoreViewState = true)
{
// A new decompile invalidates any tooltip resolved against the previous document —
// force-close even if the popup currently wants to stay (mouseClick: true).
@ -543,14 +538,15 @@ namespace ILSpy.TextView @@ -543,14 +538,15 @@ 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). PendingFoldings is set
// by Back / Forward navigation; LastKnownFoldings is captured on tab-switch so
// raw tab toggles restore state too -- prefer the explicit Pending if both
// exist (the history path wins over the recycled tab path).
var pendingFoldings = model.PendingFoldings ?? model.LastKnownFoldings;
model.PendingFoldings = null;
// Consume the pending view state 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). PendingViewState is set by Back/Forward/GoTo
// navigation; null on a fresh navigation, in which case the caret/scroll reset below
// puts the new document at the top.
var pendingState = restoreViewState ? model.PendingViewState : null;
if (restoreViewState)
model.PendingViewState = null;
var pendingFoldings = pendingState?.Foldings;
if (model.Foldings is { Count: > 0 } foldings)
{
// Defensive clamp: drop foldings that fall outside [0, TextLength]. Without
@ -593,29 +589,32 @@ namespace ILSpy.TextView @@ -593,29 +589,32 @@ namespace ILSpy.TextView
if (model.HighlightedReference != null)
HighlightLocalReferences(model, model.HighlightedReference);
// Restore caret + scroll position captured when the user previously navigated
// away from this node. Back/Forward (and the dropdown-history "GoTo") set the
// Pending* fields just before triggering the decompile; tab switches don't,
// but LastKnown* is kept fresh by the per-event push from the view, so falling
// through Pending -> LastKnown lets a raw tab toggle restore the same state.
// Clear Pending* so a subsequent user-initiated decompile doesn't jump the
// cursor again. Clamp to the new document length -- text-changed-since-capture
// is the common case for Refresh.
if ((model.PendingCaretOffset ?? model.LastKnownCaretOffset) is { } caret)
{
model.PendingCaretOffset = null;
Editor.TextArea.Caret.Offset = Math.Clamp(caret, 0, Editor.Document.TextLength);
Editor.TextArea.Caret.BringCaretToView();
}
if ((model.PendingVerticalOffset ?? model.LastKnownVerticalOffset) is { } vy)
{
model.PendingVerticalOffset = null;
Editor.ScrollToVerticalOffset(vy);
}
if ((model.PendingHorizontalOffset ?? model.LastKnownHorizontalOffset) is { } hx)
{
model.PendingHorizontalOffset = null;
Editor.ScrollToHorizontalOffset(hx);
// Restore caret + scroll for Back/Forward/GoTo (PendingViewState carries the position
// captured when the user left this node). On a fresh navigation there's no pending
// state, so reset to the top: the editor is reused across navigations and AvaloniaEdit
// keeps the previous document's caret/scroll when the text is replaced, which would
// otherwise leave a new node scrolled to wherever the previous one was. Clamp the caret
// to the new document length (text may have changed since capture, e.g. Refresh).
// AvaloniaEdit's ScrollToVerticalOffset/ScrollToHorizontalOffset are no-ops in 12.0.0
// (fixed upstream in AvaloniaUI/AvaloniaEdit#594), so set the ScrollViewer's Offset
// directly. Avalonia re-coerces Offset against the scroll extent on the next layout,
// so this sticks even though the freshly-set document isn't measured yet.
if (restoreViewState)
{
if (pendingState is { } state)
{
Editor.TextArea.Caret.Offset = Math.Clamp(state.CaretOffset, 0, Editor.Document.TextLength);
if (EditorScrollViewer is { } scrollViewer)
scrollViewer.Offset = new Vector(state.HorizontalOffset, state.VerticalOffset);
}
else
{
// Fresh navigation: reset caret + scroll to the top. The reused editor keeps the
// previous document's position when the text is replaced, so reset explicitly.
Editor.TextArea.Caret.Offset = 0;
if (EditorScrollViewer is { } scrollViewer)
scrollViewer.Offset = default;
}
}
// Swap any tab-contributed visual-line element generators (e.g. About-page hyperlink
@ -635,8 +634,6 @@ namespace ILSpy.TextView @@ -635,8 +634,6 @@ namespace ILSpy.TextView
}
Editor.TextArea.TextView.Redraw();
Editor.ScrollToHome();
}
void OnHyperlinkOpenUri(object? sender, AvaloniaEdit.Rendering.OpenUriRoutedEventArgs e)

38
ILSpy/TextView/DecompilerTextViewState.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
// 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.
namespace ILSpy.TextView
{
/// <summary>
/// Snapshot of the decompiler editor's view state at a moment in time: caret offset, scroll
/// offsets, and the set of expanded foldings. Pulled from the live editor when a navigation
/// record is written, and applied back when Back/Forward re-decompiles the node.
///
/// <para>
/// This replaces the per-axis LastKnown*/Pending* fields that used to be pushed onto the
/// view-model on every caret/scroll event. That push mistook AvaloniaEdit's programmatic
/// caret-to-end (performed when the document text is replaced) for a user move and recorded
/// it, poisoning the captured position. Reading on demand at capture time avoids that.
/// </para>
/// </summary>
public readonly record struct DecompilerTextViewState(
int CaretOffset,
double VerticalOffset,
double HorizontalOffset,
FoldingsViewState.Snapshot? Foldings);
}
Loading…
Cancel
Save