diff --git a/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs new file mode 100644 index 000000000..5e249d6d1 --- /dev/null +++ b/ILSpy.Tests/Navigation/ViewStateRoundTripTests.cs @@ -0,0 +1,140 @@ +// 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.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ILSpy.AppEnv; +using ILSpy.Navigation; +using ILSpy.TextView; +using ILSpy.TreeNodes; +using ILSpy.ViewModels; +using ILSpy.Views; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Navigation; + +/// +/// End-to-end: select node A, move the caret, select node B, navigate Back. The caret +/// must land where the user left it on A — that's the contract for Back/Forward to +/// feel like a real browser instead of a "reset to top" gesture. +/// +[TestFixture] +public class ViewStateRoundTripTests +{ + [AvaloniaTest] + public async Task Back_Restores_Caret_Position_The_User_Left_On_The_Previous_Node() + { + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var dockWorkspace = vm.DockWorkspace; + + // Pick two distinct decompiler targets so the navigation actually moves between + // them. CoreLib's System.Object and System.String are both always present. + 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!; + + // 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 + // 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. + var backEntries = dockWorkspace.BackHistory.OfType().ToList(); + backEntries.Should().NotBeEmpty("Select(B) must push A onto the back stack"); + var captured = backEntries.Last(); + ReferenceEquals(captured.Node, objectNode).Should().BeTrue( + "captured back-stack entry must reference node A"); + captured.CaretOffset.Should().Be(500); + captured.VerticalOffset.Should().Be(120.5); + captured.HorizontalOffset.Should().Be(7.25); + + // Navigate Back. The handler should set Pending* on the tab; the editor view + // applies them once Text lands. + dockWorkspace.NavigateBackCommand.Execute(null); + await dockWorkspace.WaitForDecompiledTextAsync(); + + // After the decompile lands the view consumes Pending* and clears them. To + // verify the round-trip without a real editor in headless mode, assert directly + // on the tab's LastKnown* — the view's caret-change handler writes those 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"); + tab.LastKnownVerticalOffset.Should().Be(120.5, + "Back must restore the vertical scroll offset"); + } + + [AvaloniaTest] + public async Task Forward_Is_Wired_Through_The_Toolbar_And_Key_Binding() + { + // Pins the existing Forward command wiring: the tracker had listed BrowseForward + // as missing, but it was already implemented. This test makes a regression in any + // of the three wirings (command, toolbar button, Alt+Right) detectable. + + var window = AppComposition.Current.GetExport(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + ((object?)vm.DockWorkspace.NavigateForwardCommand).Should().NotBeNull(); + ((object?)vm.DockWorkspace.NavigateBackCommand).Should().NotBeNull(); + + // Build up history: A → B, then go Back so Forward is enabled. + 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 vm.DockWorkspace.WaitForDecompiledTextAsync(); + vm.AssemblyTreeModel.SelectNode(stringNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + vm.DockWorkspace.NavigateBackCommand.Execute(null); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + vm.DockWorkspace.NavigateForwardCommand.CanExecute(null).Should().BeTrue( + "Forward must be enabled after Back leaves an entry on the forward stack"); + + vm.DockWorkspace.NavigateForwardCommand.Execute(null); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + // Back through Forward should land on B again. + ReferenceEquals(vm.AssemblyTreeModel.SelectedItem, stringNode).Should().BeTrue( + "NavigateForward must restore the tree selection to the entry that was just popped from forward"); + } +} diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index b5781cb9b..107ccfe37 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -225,11 +225,37 @@ namespace ILSpy.Docking void RecordHistoryEntry(NavigationEntry entry) { + // Snapshot the editor's caret + scroll state into the OUTGOING entry — at this + // point the editor still shows the previous selection's content, so the values + // describe where the user was looking before this new entry takes over. The + // captured state is then restored if/when the user navigates Back/Forward to it. + CaptureCurrentViewState(); history.Record(entry); NavigateBackCommand.NotifyCanExecuteChanged(); NavigateForwardCommand.NotifyCanExecuteChanged(); } + void CaptureCurrentViewState() + { + if (history.Current is not TreeNodeEntry current) + return; + var decompTab = UnwrapDecompilerTab(current.Tab); + if (decompTab == null) + return; + current.CaretOffset = decompTab.LastKnownCaretOffset; + current.VerticalOffset = decompTab.LastKnownVerticalOffset; + current.HorizontalOffset = decompTab.LastKnownHorizontalOffset; + } + + // The recorded TabPageModel in TreeNodeEntry can be either a DecompilerTabPageModel + // directly OR a ContentTabPage whose Content is one — `factory.Documents.ActiveDockable` + // returns the latter in the normal layout. Try both shapes. + static TextView.DecompilerTabPageModel? UnwrapDecompilerTab(ViewModels.TabPageModel? tab) => tab switch { + TextView.DecompilerTabPageModel d => d, + ViewModels.ContentTabPage c => c.Content as TextView.DecompilerTabPageModel, + _ => null, + }; + void NavigateBack() => NavigateHistory(forward: false); void NavigateForward() => NavigateHistory(forward: true); @@ -261,7 +287,18 @@ namespace ILSpy.Docking if (factory.Documents?.VisibleDockables is { } docs && docs.Contains(target.Tab)) factory.SetActiveDockable(target.Tab); if (target is TreeNodeEntry treeNode) + { + // Stash the captured view state on the tab BEFORE setting SelectedItem. + // SelectedItem triggers the decompile; the editor's ApplyDocument reads + // 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; + } assemblyTreeModel.SelectedItem = treeNode.Node; + } // StaticPageEntry: just reactivating the tab is enough — its content was // preserved because IsStaticContent kept tree-node selections from targeting it. } diff --git a/ILSpy/NavigationEntry.cs b/ILSpy/NavigationEntry.cs index 36de107ad..9bb452da8 100644 --- a/ILSpy/NavigationEntry.cs +++ b/ILSpy/NavigationEntry.cs @@ -57,12 +57,28 @@ namespace ILSpy.Navigation /// /// History entry for a tree-node selection. Replaying activates the recorded tab and sets - /// the assembly-tree selection to . + /// the assembly-tree selection to . The optional caret + scroll fields + /// capture the editor's view state when the user navigated AWAY from this entry, so + /// Back/Forward restores cursor position and scroll position alongside the selection. /// public sealed class TreeNodeEntry : NavigationEntry { public SharpTreeNode Node { get; } + /// + /// Caret offset (character index into the decompiled text) captured when the user + /// navigated away from this entry. null means "no capture yet" — entries + /// land with null and are filled in by 's + /// record-history hook just before a new entry takes over. + /// + public int? CaretOffset { get; set; } + + /// Vertical scroll offset captured at navigate-away time. + public double? VerticalOffset { get; set; } + + /// Horizontal scroll offset captured at navigate-away time. + public double? HorizontalOffset { get; set; } + public TreeNodeEntry(TabPageModel tab, SharpTreeNode node) : base(tab) { diff --git a/ILSpy/NavigationHistory.cs b/ILSpy/NavigationHistory.cs index 5be4a60e9..a59d85269 100644 --- a/ILSpy/NavigationHistory.cs +++ b/ILSpy/NavigationHistory.cs @@ -39,6 +39,14 @@ namespace ILSpy.Navigation public bool CanNavigateBack => back.Count > 0; public bool CanNavigateForward => forward.Count > 0; + /// + /// The most recently navigated-to entry, or null if no navigation has happened + /// yet. Mutable callers (e.g. DockWorkspace.CaptureCurrentViewState) reach + /// in here to stamp the editor's caret + scroll state into the entry just before + /// it gets pushed onto the back stack — so a subsequent Back restores the position. + /// + public T? Current => current; + // Read-only views over the history stacks for the toolbar's split-button dropdowns. // Both lists are oldest-first (matches push/append order); the UI reverses for "newest // first" display. diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 52cd4c028..862e83c25 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -135,6 +135,37 @@ namespace ILSpy.TextView [ObservableProperty] private object? highlightedReference; + /// + /// Last caret offset the editor view reported. The text view writes this on every + /// caret-position change so 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. + /// + public int LastKnownCaretOffset { get; set; } + + /// Last vertical scroll offset the editor view reported. See . + public double LastKnownVerticalOffset { get; set; } + + /// Last horizontal scroll offset the editor view reported. See . + public double LastKnownHorizontalOffset { get; set; } + + /// + /// 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 ); cleared by the view + /// after it reads the value. The pattern mirrors — + /// the editor consumes this in its ApplyDocument path so the value survives + /// the async gap between Back firing and the decompile finishing. + /// + public int? PendingCaretOffset { get; set; } + + /// Vertical scroll offset to restore alongside . + public double? PendingVerticalOffset { get; set; } + + /// Horizontal scroll offset to restore alongside . + public double? PendingHorizontalOffset { 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 beda8714c..d9fc479b7 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -166,6 +166,28 @@ namespace ILSpy.TextView // need to be in any visual tree of ours. 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. + Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; + Editor.TextArea.TextView.ScrollOffsetChanged += OnScrollOffsetChanged; + } + + void OnCaretPositionChanged(object? sender, EventArgs e) + { + if (DataContext is DecompilerTabPageModel m) + m.LastKnownCaretOffset = Editor.TextArea.Caret.Offset; + } + + void OnScrollOffsetChanged(object? sender, EventArgs e) + { + if (DataContext is DecompilerTabPageModel m) + { + m.LastKnownVerticalOffset = Editor.VerticalOffset; + m.LastKnownHorizontalOffset = Editor.HorizontalOffset; + } } /// @@ -385,6 +407,29 @@ 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; here we read them once + // and clear them 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 is { } caret) + { + model.PendingCaretOffset = null; + Editor.TextArea.Caret.Offset = Math.Clamp(caret, 0, Editor.Document.TextLength); + Editor.TextArea.Caret.BringCaretToView(); + } + if (model.PendingVerticalOffset is { } vy) + { + model.PendingVerticalOffset = null; + Editor.ScrollToVerticalOffset(vy); + } + if (model.PendingHorizontalOffset is { } hx) + { + model.PendingHorizontalOffset = null; + Editor.ScrollToHorizontalOffset(hx); + } + // Swap any tab-contributed visual-line element generators (e.g. About-page hyperlink // generators). Tracked separately from the always-on referenceElementGenerator and // uiElementGenerator so the two sets don't collide on tab change.