diff --git a/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs b/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs index 4abb404f3c..fad106dc46 100644 --- a/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs +++ b/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs @@ -18,7 +18,6 @@ using System; using System.IO; -using System.Threading; using System.Xml; using System.Xml.Linq; @@ -129,34 +128,5 @@ static string GetConfigFile() } const string ConfigFileMutex = "01A91708-49D1-410D-B8EB-4DE2662B3971"; - - /// - /// Helper class for serializing access to the config file when multiple ILSpy instances are running. - /// - sealed class MutexProtector : IDisposable - { - readonly Mutex mutex; - - public MutexProtector(string name) - { - this.mutex = new Mutex(true, name, out bool createdNew); - if (createdNew) - return; - - try - { - mutex.WaitOne(); - } - catch (AbandonedMutexException) - { - } - } - - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } } } diff --git a/ICSharpCode.ILSpyX/Settings/MutexProtector.cs b/ICSharpCode.ILSpyX/Settings/MutexProtector.cs new file mode 100644 index 0000000000..2ca7aa760c --- /dev/null +++ b/ICSharpCode.ILSpyX/Settings/MutexProtector.cs @@ -0,0 +1,55 @@ +// 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; +using System.Threading; + +namespace ICSharpCode.ILSpyX.Settings +{ + /// + /// Guards a config-file read-modify-write with a named so that two ILSpy + /// instances editing the same sidecar (the settings XML, the bookmark list, ...) fold their + /// changes in turn instead of overwriting each other. Acquire it around the + /// read-update-write block and dispose it to release. + /// + public sealed class MutexProtector : IDisposable + { + readonly Mutex mutex; + + public MutexProtector(string name) + { + this.mutex = new Mutex(true, name, out bool createdNew); + if (createdNew) + return; + + try + { + mutex.WaitOne(); + } + catch (AbandonedMutexException) + { + } + } + + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} diff --git a/ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs b/ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs new file mode 100644 index 0000000000..f421ec1074 --- /dev/null +++ b/ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs @@ -0,0 +1,62 @@ +// 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; +using System.IO; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpyX.Settings; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.AppEnv; + +// ConfigurationFiles.GetPath underpins where the dock layout and the bookmark list are +// stored: as JSON sidecars in the same directory as ILSpy.xml. These tests pin that +// "next to the settings file" contract and the headless fallback. +[TestFixture] +public class ConfigurationFilesTests +{ + Func? savedProvider; + + [SetUp] + public void SaveProvider() => savedProvider = ILSpySettings.SettingsFilePathProvider; + + [TearDown] + public void RestoreProvider() => ILSpySettings.SettingsFilePathProvider = savedProvider; + + [Test] + public void GetPath_returns_sidecar_in_settings_directory() + { + var settingsDir = Path.Combine(Path.GetTempPath(), "ILSpyConfigTest"); + ILSpySettings.SettingsFilePathProvider = () => Path.Combine(settingsDir, "ILSpy.xml"); + + ConfigurationFiles.GetPath("ILSpy.Bookmarks.json") + .Should().Be(Path.Combine(settingsDir, "ILSpy.Bookmarks.json")); + } + + [Test] + public void GetPath_falls_back_to_bare_name_when_provider_is_absent() + { + ILSpySettings.SettingsFilePathProvider = null; + + ConfigurationFiles.GetPath("ILSpy.Bookmarks.json").Should().Be("ILSpy.Bookmarks.json"); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs new file mode 100644 index 0000000000..4e1a7a5aad --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs @@ -0,0 +1,82 @@ +// 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 AvaloniaEdit.Document; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +// End-to-end anchoring against real decompiled output: a statement line yields a body anchor, +// a definition line yields a token anchor, and a comment line yields nothing. +[TestFixture] +public class BookmarkAnchoringTests +{ + [AvaloniaTest] + public async Task Decompiled_type_offers_both_body_and_token_anchors() + { + var (_, vm) = await TestHarness.BootAsync(3); + var csharp = AppComposition.Current.GetExport().Languages.First(l => l.Name == "C#"); + + var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); + var typeSystem = asm!.LoadedAssembly.GetTypeSystemOrNull(); + var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == "System.Linq.Enumerable"); + + var output = new AvaloniaEditTextOutput(); + csharp.DecompileType(type, output, new DecompilationOptions(new DecompilerSettings())); + + var document = new TextDocument(output.GetText()); + var debugInfo = new DecompiledDebugInfo(output.MethodDebugInfos); + + Bookmark? body = null, token = null; + for (int line = 1; line <= document.LineCount; line++) + { + var bookmark = BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, line); + if (bookmark?.Kind == BookmarkKind.Body) + body ??= bookmark; + else if (bookmark?.Kind == BookmarkKind.Token) + token ??= bookmark; + } + + body.Should().NotBeNull("statement lines must produce a body anchor"); + token.Should().NotBeNull("definition lines must produce a token anchor"); + + + // Line 1 is the "// " comment: neither a statement nor a definition, + // so it falls back to the visible line in the current decompiled document. + var fallback = BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, 1, type); + fallback.Should().NotBeNull(); + fallback!.Kind.Should().Be(BookmarkKind.Line); + fallback.LineNumber.Should().Be(1); + fallback.Token.Should().Be((uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(type.MetadataToken)); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs b/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs new file mode 100644 index 0000000000..1acc4430f5 --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs @@ -0,0 +1,138 @@ +// 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.Reflection; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; +using Avalonia.Threading; + +using AvaloniaEdit.Document; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +[TestFixture] +public class BookmarkContextMenuTests +{ + [AvaloniaTest] + public async Task Toggle_Bookmark_Entry_Acts_On_The_Right_Clicked_Line_Not_The_Caret() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + + var bookmarkableLines = Enumerable.Range(1, view.Editor.Document.LineCount) + .Where(view.CanToggleBookmarkAtLine) + .Take(2) + .ToArray(); + bookmarkableLines.Should().HaveCount(2, "two distinct bookmarkable lines are needed to tell the caret from the click"); + + int caretLine = bookmarkableLines[0]; + int clickedLine = bookmarkableLines[1]; + view.Editor.TextArea.Caret.Offset = view.Editor.Document.GetLineByNumber(caretLine).Offset; + int clickedOffset = view.Editor.Document.GetLineByNumber(clickedLine).Offset; + + var toggle = AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.BookmarkToggle)); + toggle.Execute(new TextViewContext { TextView = view, TextLocation = clickedOffset }); + Dispatcher.UIThread.RunJobs(); + + manager.Bookmarks.Should().ContainSingle(); + view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(clickedLine); + manager.Bookmarks[0].LocationNodeName.Should().Be("System.Object"); + } + + [AvaloniaTest] + public async Task Navigation_Does_Not_Fall_Back_To_Entity_When_Saved_Tree_Path_Is_Missing() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(objectNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + + int line = Enumerable.Range(1, view.Editor.Document.LineCount) + .First(view.CanToggleBookmarkAtLine); + int offset = view.Editor.Document.GetLineByNumber(line).Offset; + AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.BookmarkToggle)) + .Execute(new TextViewContext { TextView = view, TextLocation = offset }); + Dispatcher.UIThread.RunJobs(); + + manager.Bookmarks.Should().ContainSingle(); + var bookmark = manager.Bookmarks[0]; + bookmark.ViewState.Should().NotBeNull(); + bookmark.ViewState = bookmark.ViewState! with { SelectedTreeNodePath = new[] { "Missing assembly", "Missing type" } }; + + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + + await AppComposition.Current.GetExport().NavigateToAsync(bookmark); + + ((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(stringNode); + } + + [AvaloniaTest] + public async Task Queued_Bookmark_Scroll_Ignores_A_Replaced_Document() + { + var (window, _) = await TestHarness.BootAsync(3); + var view = await window.WaitForComponent(); + view.Editor.Document = new TextDocument(string.Join("\n", Enumerable.Range(1, 30).Select(i => $"line {i}"))); + + var scrollToLine = typeof(DecompilerTextView).GetMethod("ScrollToLine", BindingFlags.Instance | BindingFlags.NonPublic); + scrollToLine.Should().NotBeNull(); + scrollToLine!.Invoke(view, new object?[] { 20, null }); + + view.Editor.Document = new TextDocument("replacement"); + Dispatcher.UIThread.RunJobs(); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs b/ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs new file mode 100644 index 0000000000..8a3fcceddc --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs @@ -0,0 +1,71 @@ +// 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.Reflection.Metadata.Ecma335; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.Decompiler; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +// Proves the sequence-point capture in CSharpLanguage.WriteCode actually populates the +// per-document debug map against real decompiled output -- the foundation of the body anchor. +[TestFixture] +public class BookmarkDebugInfoCaptureTests +{ + [AvaloniaTest] + public async Task CSharp_decompile_captures_a_method_map_that_round_trips() + { + var (_, vm) = await TestHarness.BootAsync(3); + var csharp = AppComposition.Current.GetExport().Languages.First(l => l.Name == "C#"); + + var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); + Assert.That(asm, Is.Not.Null); + var typeSystem = asm!.LoadedAssembly.GetTypeSystemOrNull(); + Assert.That(typeSystem, Is.Not.Null); + var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == "System.Linq.Enumerable"); + var method = type.Methods.First(m => m.HasBody && !m.IsConstructor); + + var output = new AvaloniaEditTextOutput(); + csharp.DecompileMethod(method, output, new DecompilationOptions(new DecompilerSettings())); + + output.MethodDebugInfos.Should().NotBeEmpty("a method with a body emits sequence points"); + + uint token = (uint)MetadataTokens.GetToken(method.MetadataToken); + var map = output.MethodDebugInfos.FirstOrDefault(m => m.Token == token); + map.Should().NotBeNull("the decompiled method itself must be in the map"); + + // The first statement (IL offset 0) maps to a real line, and that line maps back to offset 0. + map!.TryGetLineForOffset(0, out var line).Should().BeTrue(); + int lineCount = output.GetText().Replace("\r\n", "\n").Split('\n').Length; + line.Should().BeInRange(1, lineCount); + map.TryGetOffsetForLine(line, out var offset).Should().BeTrue(); + offset.Should().Be(0); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs new file mode 100644 index 0000000000..d73f845bce --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -0,0 +1,278 @@ +// 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; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; + +using AvaloniaEdit.Rendering; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +[TestFixture] +public class BookmarkGutterTests +{ + [AvaloniaTest] + public async Task Clicking_Bookmark_Gutter_Toggles_The_Visible_Line() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + window.UpdateLayout(); + + var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); + margin.Bounds.Width.Should().BeGreaterThan(0, "the bookmark gutter must reserve hit-testable space"); + + var textView = view.Editor.TextArea.TextView; + textView.EnsureVisualLines(); + var visualLine = textView.VisualLines.First(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber)); + int documentLine = visualLine.FirstDocumentLine.LineNumber; + double y = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset; + y.Should().BeInRange(0, margin.Bounds.Height, "the selected visual line must be inside the gutter bounds"); + var point = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window); + point.Should().NotBeNull("the gutter line coordinate must map into the test window"); + + int pressed = 0; + margin.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressed++, RoutingStrategies.Tunnel | RoutingStrategies.Bubble); + + window.MouseDown(point!.Value, MouseButton.Left); + window.MouseUp(point.Value, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + pressed.Should().BeGreaterThan(0, "the real pointer event must hit the bookmark gutter"); + manager.Bookmarks.Should().ContainSingle(); + view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(documentLine); + + window.MouseDown(point.Value, MouseButton.Left); + window.MouseUp(point.Value, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + manager.Bookmarks.Should().BeEmpty(); + } + + [AvaloniaTest] + public async Task RightClicking_Bookmark_Gutter_Does_Not_Toggle() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + window.UpdateLayout(); + + var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); + var textView = view.Editor.TextArea.TextView; + textView.EnsureVisualLines(); + var visualLine = textView.VisualLines.First(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber)); + double y = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset; + var point = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window); + point.Should().NotBeNull("the gutter line coordinate must map into the test window"); + + window.MouseDown(point!.Value, MouseButton.Right); + window.MouseUp(point.Value, MouseButton.Right); + Dispatcher.UIThread.RunJobs(); + + manager.Bookmarks.Should().BeEmpty("a right-click in the gutter must not toggle a bookmark"); + + // The same coordinate still toggles on a left-click, proving the gesture is button-gated + // rather than disabled at that line. + window.MouseDown(point.Value, MouseButton.Left); + window.MouseUp(point.Value, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + manager.Bookmarks.Should().ContainSingle("a left-click at the same coordinate still toggles"); + } + + [AvaloniaTest] + public async Task Removing_A_Bookmark_From_The_Gutter_Hides_Its_Hover_Preview_Until_The_Pointer_Leaves_The_Line() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + var coreLibName = typeof(object).Assembly.GetName().Name!; + var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(typeNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + window.UpdateLayout(); + + var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); + var textView = view.Editor.TextArea.TextView; + textView.EnsureVisualLines(); + var bookmarkable = textView.VisualLines + .Where(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber)) + .Take(2) + .ToArray(); + bookmarkable.Should().HaveCount(2, "two bookmarkable lines are needed to leave and re-enter the removed line"); + + Point GutterPoint(VisualLine line) + { + double y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset; + var p = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window); + p.Should().NotBeNull("the gutter line coordinate must map into the test window"); + return p!.Value; + } + + int line0 = bookmarkable[0].FirstDocumentLine.LineNumber; + int line1 = bookmarkable[1].FirstDocumentLine.LineNumber; + Point point0 = GutterPoint(bookmarkable[0]); + Point point1 = GutterPoint(bookmarkable[1]); + + // Hovering an empty bookmarkable line previews a ghost glyph. + window.MouseMove(point0); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(line0, "hovering a bookmarkable line previews its glyph"); + + // Click adds the bookmark; the pointer stays on the line. + window.MouseDown(point0, MouseButton.Left); + window.MouseUp(point0, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + manager.Bookmarks.Should().ContainSingle(); + + // Clicking again removes it: the line must visibly empty rather than redraw a ghost. + window.MouseDown(point0, MouseButton.Left); + window.MouseUp(point0, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + manager.Bookmarks.Should().BeEmpty(); + margin.HoverPreviewLine.Should().Be(-1, "removing via the gutter hides the preview"); + + // A jitter that stays on the just-removed line must NOT bring the preview back. + window.MouseMove(new Point(point0.X + 1, point0.Y)); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(-1, "moving within the same line keeps the removed line's preview hidden"); + + // Moving onto a different line resumes normal hover. + window.MouseMove(point1); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(line1, "a different line hovers normally"); + + // Returning to the original line now shows its preview again. + window.MouseMove(point0); + Dispatcher.UIThread.RunJobs(); + margin.HoverPreviewLine.Should().Be(line0, "leaving and re-entering the line restores its preview"); + } + + // Regression: the editor reuses one TextDocument and only swaps its text on navigation, so the + // gutter's cached glyph map must rebuild off the document's content (DebugInfo/References), not off + // the document reference -- otherwise switching nodes leaves the gutter showing the previous (or no) + // document's bookmarks. + [AvaloniaTest] + public async Task Gutter_Glyph_Map_Rebuilds_When_The_Displayed_Document_Changes() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + var coreLibName = typeof(object).Assembly.GetName().Name!; + + async Task Show(string typeName) + { + var node = vm.AssemblyTreeModel.FindNode(coreLibName, "System", typeName); + vm.AssemblyTreeModel.SelectNode(node); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var shown = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + return shown; + } + + var view = await Show("System.Object"); + var margin = view.Editor.TextArea.LeftMargins.OfType().Single(); + var toggle = AppComposition.Current.GetExport().GetEntry(nameof(Resources.BookmarkToggle)); + + // A body (IL-offset) anchor, so it only resolves inside System.Object -- a token/line anchor on + // the type would also resolve in System.String, which merely references System.Object. + Bookmark? bookmark = null; + foreach (int candidate in Enumerable.Range(1, view.Editor.Document.LineCount).Where(view.CanToggleBookmarkAtLine)) + { + int candidateOffset = view.Editor.Document.GetLineByNumber(candidate).Offset; + view.Editor.TextArea.Caret.Offset = candidateOffset; + toggle.Execute(new TextViewContext { TextView = view, TextLocation = candidateOffset }); + Dispatcher.UIThread.RunJobs(); + var candidateBookmark = manager.Bookmarks.Single(); + if (candidateBookmark.Kind == BookmarkKind.Body) + { + bookmark = candidateBookmark; + break; + } + manager.Clear(); + } + bookmark.Should().NotBeNull("System.Object has a method-body line to anchor to"); + int objectLine = view.GetLineForBookmark(bookmark!)!.Value; + margin.GlyphLinesForTest().Keys.Should().Contain(objectLine, "the gutter shows the bookmark on its own document"); + + // Navigate to another type. The preview tab reuses the same view (and gutter), only swapping the + // document text, so the gutter must drop the stale glyph: System.Object's bookmark is not here. + var stringView = await Show("System.String"); + stringView.Should().BeSameAs(view, "the preview tab reuses the view, so this exercises the stale cache"); + margin.GlyphLinesForTest().Should().BeEmpty("the gutter must rebuild for System.String, where the bookmark does not resolve"); + + // Returning to the bookmarked document must surface its glyph again. + await Show("System.Object"); + margin.GlyphLinesForTest().Keys.Should().Contain(view.GetLineForBookmark(bookmark!)!.Value, + "returning to the bookmarked document must show its glyph again"); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs new file mode 100644 index 0000000000..6b142338db --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs @@ -0,0 +1,375 @@ +// 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; +using System.IO; +using System.Linq; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpyX.Settings; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +// Exercises the manager's pure list/persistence behaviour without the decompiler: toggle, +// default naming, JSON round-trip, and the merge-vs-replace import rules. +[TestFixture] +public class BookmarkManagerTests +{ + Func? savedProvider; + string tempDir = ""; + + [SetUp] + public void SetUp() + { + savedProvider = ILSpySettings.SettingsFilePathProvider; + tempDir = Path.Combine(Path.GetTempPath(), "ILSpyBookmarkTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + ILSpySettings.SettingsFilePathProvider = () => Path.Combine(tempDir, "ILSpy.xml"); + } + + [TearDown] + public void TearDown() + { + ILSpySettings.SettingsFilePathProvider = savedProvider; + try + { + Directory.Delete(tempDir, recursive: true); + } + catch + { + // best effort + } + } + + // A manager whose sidecar lives in its own subdirectory, so two managers in one test don't + // share (and preload) each other's saved list through the common settings path. + BookmarkManager NewManagerInFreshDir() + { + var dir = Path.Combine(tempDir, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + ILSpySettings.SettingsFilePathProvider = () => Path.Combine(dir, "ILSpy.xml"); + return new BookmarkManager(); + } + + static Bookmark MakeBookmark(uint token, BookmarkKind kind = BookmarkKind.Token, int ilOffset = 0, string name = "", int lineNumber = 1, string? locationNodeName = null) + => new() { + Name = name, + FileName = @"C:\asm\Sample.dll", + AssemblyFullName = "Sample, Version=1.0.0.0", + ModuleName = "Sample.dll", + Token = token, + Kind = kind, + ILOffset = ilOffset, + LineNumber = lineNumber, + MemberName = "Sample.Type.Member", + LocationNodeName = locationNodeName, + }; + + [Test] + public void Toggle_adds_then_removes_the_same_anchor() + { + var manager = new BookmarkManager(); + + manager.Toggle(MakeBookmark(0x06000001)).Should().BeTrue(); + manager.Bookmarks.Should().HaveCount(1); + + manager.Toggle(MakeBookmark(0x06000001)).Should().BeFalse(); + manager.Bookmarks.Should().BeEmpty(); + } + + [Test] + public void Toggle_assigns_a_default_name_when_none_given() + { + var manager = new BookmarkManager(); + + manager.Toggle(MakeBookmark(0x06000001)); + manager.Toggle(MakeBookmark(0x06000002)); + + manager.Bookmarks.Select(b => b.Name).Should().Equal("Bookmark0", "Bookmark1"); + } + + [Test] + public void Body_anchors_with_different_offsets_are_distinct() + { + var manager = new BookmarkManager(); + + manager.Toggle(MakeBookmark(0x06000001, BookmarkKind.Body, ilOffset: 0)); + manager.Toggle(MakeBookmark(0x06000001, BookmarkKind.Body, ilOffset: 8)); + + manager.Bookmarks.Should().HaveCount(2); + } + + [Test] + public void Line_anchors_with_different_visible_lines_are_distinct() + { + var manager = new BookmarkManager(); + + manager.Toggle(MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 3)); + manager.Toggle(MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 4)); + + manager.Bookmarks.Should().HaveCount(2); + } + + [Test] + public void Adding_a_bookmark_preserves_existing_live_instances() + { + var manager = new BookmarkManager(); + manager.Toggle(MakeBookmark(0x06000001, name: "A")); + var first = manager.Bookmarks[0]; + + manager.Toggle(MakeBookmark(0x06000002, name: "B")); + + manager.Bookmarks.Should().HaveCount(2); + // A structural change must reconcile the live list, not rebuild it: the bookmarks pane holds a + // live selection by reference, so replacing every instance on each toggle would drop it. + manager.Bookmarks[0].Should().BeSameAs(first, "adding a bookmark must not replace existing instances"); + } + + [Test] + public void Editing_a_bookmark_keeps_the_other_live_instances_intact() + { + var manager = new BookmarkManager(); + manager.Toggle(MakeBookmark(0x06000001, name: "A")); + manager.Toggle(MakeBookmark(0x06000002, name: "B")); + var first = manager.Bookmarks[0]; + var second = manager.Bookmarks[1]; + + // Editing a name (as the pane's Name editor does) must persist without tearing down the list. + first.Name = "A renamed"; + + manager.Bookmarks[0].Should().BeSameAs(first); + manager.Bookmarks[1].Should().BeSameAs(second, "editing one bookmark must not replace the others"); + new BookmarkManager().Bookmarks.Single(b => b.Token == 0x06000001).Name.Should().Be("A renamed"); + } + + [Test] + public void Saved_bookmarks_reload_in_a_fresh_manager() + { + var first = new BookmarkManager(); + first.Toggle(MakeBookmark(0x06000001, BookmarkKind.Body, ilOffset: 4, name: "Mine")); + + var second = new BookmarkManager(); + + second.Bookmarks.Should().HaveCount(1); + var reloaded = second.Bookmarks[0]; + reloaded.Name.Should().Be("Mine"); + reloaded.Token.Should().Be(0x06000001); + reloaded.Kind.Should().Be(BookmarkKind.Body); + reloaded.ILOffset.Should().Be(4); + } + + [Test] + public void Saved_line_bookmarks_reload_in_a_fresh_manager() + { + var first = new BookmarkManager(); + first.Toggle(MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 7, name: "Header", locationNodeName: "Sample.Type")); + + var second = new BookmarkManager(); + + second.Bookmarks.Should().ContainSingle(); + var reloaded = second.Bookmarks[0]; + reloaded.Kind.Should().Be(BookmarkKind.Line); + reloaded.LineNumber.Should().Be(7); + reloaded.LocationNodeName.Should().Be("Sample.Type"); + } + + [Test] + public void Saved_bookmarks_reload_their_view_state_payload() + { + var first = new BookmarkManager(); + var bookmark = MakeBookmark(0x06000001, name: "With view"); + bookmark.ViewState = new BookmarkViewState(1, 42, 120.5, 7.25, 100, + new[] { new BookmarkFoldingRange(10, 20) }, SelectedTreeNodePath: new[] { "Sample", "Sample.Type" }); + first.Toggle(bookmark); + + var second = new BookmarkManager(); + + second.Bookmarks.Should().ContainSingle(); + second.Bookmarks[0].ViewState.Should().NotBeNull(); + second.Bookmarks[0].ViewState!.CaretOffset.Should().Be(42); + second.Bookmarks[0].ViewState!.ExpandedFoldings.Should().ContainSingle() + .Which.Should().Be(new BookmarkFoldingRange(10, 20)); + second.Bookmarks[0].ViewState!.SelectedTreeNodePath.Should().Equal("Sample", "Sample.Type"); + } + + [Test] + public void Display_location_shows_node_name_and_line() + { + var bookmark = MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 7, locationNodeName: "Sample.Type.Node"); + bookmark.ViewState = new BookmarkViewState(1, 42, 120.5, 7.25, null, null, + SelectedTreeNodePath: new[] { "Sample", "Sample.Type" }); + + bookmark.DisplayLocation.Should().Be("Sample.Type.Node:7"); + } + + [Test] + public void Display_location_uses_resolved_rendered_line_when_it_was_not_stored() + { + var bookmark = MakeBookmark(0x02000001, BookmarkKind.Token, lineNumber: 0, locationNodeName: "Sample.Type.Node"); + + bookmark.DisplayLocation.Should().Be("Sample.Type.Node"); + + bookmark.UpdateRenderedLineNumber(59); + + bookmark.LineNumber.Should().Be(59); + bookmark.DisplayLocation.Should().Be("Sample.Type.Node:59"); + } + + [Test] + public void Two_managers_adding_different_bookmarks_preserve_both_entries() + { + var first = new BookmarkManager(); + var second = new BookmarkManager(); + + first.Toggle(MakeBookmark(0x06000001, name: "First")); + second.Toggle(MakeBookmark(0x06000002, name: "Second")); + + var reloaded = new BookmarkManager(); + + reloaded.Bookmarks.Select(b => b.Name).Should().BeEquivalentTo("First", "Second"); + } + + [Test] + public void Two_managers_editing_the_same_anchor_keep_the_later_value() + { + var first = new BookmarkManager(); + first.Toggle(MakeBookmark(0x06000001, name: "Original")); + var second = new BookmarkManager(); + + first.Bookmarks[0].Name = "First edit"; + second.Bookmarks[0].Name = "Second edit"; + + var reloaded = new BookmarkManager(); + + reloaded.Bookmarks.Should().ContainSingle(); + reloaded.Bookmarks[0].Name.Should().Be("Second edit"); + } + + [Test] + public void Two_managers_remove_and_add_preserve_the_added_bookmark() + { + var first = new BookmarkManager(); + first.Toggle(MakeBookmark(0x06000001, name: "Remove me")); + var second = new BookmarkManager(); + + first.Remove(first.Bookmarks[0]); + second.Toggle(MakeBookmark(0x06000002, name: "Keep me")); + + var reloaded = new BookmarkManager(); + + reloaded.Bookmarks.Select(b => b.Name).Should().Equal("Keep me"); + } + + [Test] + public void Malformed_bookmark_file_recovers_on_next_update() + { + File.WriteAllText(Path.Combine(tempDir, "ILSpy.Bookmarks.json"), "not json"); + var manager = new BookmarkManager(); + + manager.Toggle(MakeBookmark(0x06000001, name: "Recovered")); + + var reloaded = new BookmarkManager(); + reloaded.Bookmarks.Select(b => b.Name).Should().Equal("Recovered"); + } + + [Test] + public void Bookmarks_without_a_file_are_dropped_on_load() + { + var dir = Path.Combine(tempDir, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(dir); + ILSpySettings.SettingsFilePathProvider = () => Path.Combine(dir, "ILSpy.xml"); + // A stray empty entry (e.g. a committed grid placeholder row) alongside a real bookmark. + var json = "{ \"Version\": 1, \"Bookmarks\": [" + + "{ \"Name\": \"\", \"Enabled\": true, \"FileName\": \"\", \"AssemblyFullName\": \"\", \"ModuleName\": \"\", \"Token\": 0, \"Kind\": \"Token\", \"ILOffset\": 0, \"MemberName\": \"\" }," + + "{ \"Name\": \"Good\", \"Enabled\": true, \"FileName\": \"C:\\\\asm\\\\Sample.dll\", \"AssemblyFullName\": \"Sample\", \"ModuleName\": \"Sample.dll\", \"Token\": 100663297, \"Kind\": \"Token\", \"ILOffset\": 0, \"MemberName\": \"Sample.T.M\" }" + + "] }"; + File.WriteAllText(Path.Combine(dir, "ILSpy.Bookmarks.json"), json); + + var manager = new BookmarkManager(); + + manager.Bookmarks.Select(b => b.Name).Should().Equal("Good"); + } + + [Test] + public void Export_then_import_replace_roundtrips() + { + var source = NewManagerInFreshDir(); + source.Toggle(MakeBookmark(0x06000001, name: "A")); + source.Toggle(MakeBookmark(0x06000002, name: "B")); + var exportPath = Path.Combine(tempDir, "export.json"); + source.Export(exportPath); + + var target = NewManagerInFreshDir(); + target.Toggle(MakeBookmark(0x0600000F, name: "Old")); + target.Import(exportPath, BookmarkImportMode.Replace); + + target.Bookmarks.Select(b => b.Name).Should().Equal("A", "B"); + } + + [Test] + public void Import_merge_adds_only_new_anchors() + { + var source = NewManagerInFreshDir(); + source.Toggle(MakeBookmark(0x06000001, name: "Shared")); + source.Toggle(MakeBookmark(0x06000002, name: "New")); + var exportPath = Path.Combine(tempDir, "export.json"); + source.Export(exportPath); + + var target = NewManagerInFreshDir(); + target.Toggle(MakeBookmark(0x06000001, name: "Existing")); + target.Import(exportPath, BookmarkImportMode.Merge); + + // The shared anchor keeps the existing entry; only the genuinely new one is appended. + target.Bookmarks.Select(b => b.Name).Should().Equal("Existing", "New"); + } + + [Test] + public void Import_replace_of_a_corrupt_file_keeps_the_existing_bookmarks() + { + var corruptPath = Path.Combine(tempDir, "corrupt.json"); + File.WriteAllText(corruptPath, "{ this is not valid json"); + + var target = NewManagerInFreshDir(); + target.Toggle(MakeBookmark(0x06000001, name: "Keep")); + + bool ok = target.Import(corruptPath, BookmarkImportMode.Replace); + + ok.Should().BeFalse("a file that cannot be parsed is not a successful import"); + target.Bookmarks.Select(b => b.Name).Should().Equal(new[] { "Keep" }); + } + + [Test] + public void Import_replace_of_a_valid_empty_file_clears_the_bookmarks() + { + var source = NewManagerInFreshDir(); + var emptyExport = Path.Combine(tempDir, "empty.json"); + source.Export(emptyExport); + + var target = NewManagerInFreshDir(); + target.Toggle(MakeBookmark(0x06000001, name: "Old")); + + bool ok = target.Import(emptyExport, BookmarkImportMode.Replace); + + ok.Should().BeTrue("a valid empty file is a successful import"); + target.Bookmarks.Should().BeEmpty("Replace with a valid empty file legitimately clears the list"); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs new file mode 100644 index 0000000000..84244e7efc --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs @@ -0,0 +1,75 @@ +// 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 AwesomeAssertions; + +using ICSharpCode.ILSpy.Bookmarks; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +// Next/Previous bookmark navigation must skip disabled bookmarks while still wrapping around. +[TestFixture] +public class BookmarkNavigationStepTests +{ + // Three bookmarks with the middle one disabled, so next/previous must step over it. + static readonly IReadOnlyList ThreeMiddleDisabled = new[] { true, false, true }; + + [Test] + public void Previous_from_the_third_skips_the_disabled_second() + { + // On index 2, "previous" must land on 0, not the disabled 1. + BookmarksPaneModel.NextEnabledIndex(ThreeMiddleDisabled, selectedIndex: 2, delta: -1).Should().Be(0); + } + + [Test] + public void Next_from_the_first_skips_the_disabled_second() + { + BookmarksPaneModel.NextEnabledIndex(ThreeMiddleDisabled, selectedIndex: 0, delta: 1).Should().Be(2); + } + + [Test] + public void Next_wraps_around_past_a_trailing_disabled() + { + // [enabled, enabled, disabled]; next from index 1 wraps to 0 (2 is disabled). + BookmarksPaneModel.NextEnabledIndex(new[] { true, true, false }, selectedIndex: 1, delta: 1).Should().Be(0); + } + + [Test] + public void No_selection_picks_the_first_enabled_forward_and_last_enabled_backward() + { + var list = new[] { false, true, true, false }; + BookmarksPaneModel.NextEnabledIndex(list, selectedIndex: -1, delta: 1).Should().Be(1); + BookmarksPaneModel.NextEnabledIndex(list, selectedIndex: -1, delta: -1).Should().Be(2); + } + + [Test] + public void All_disabled_yields_nothing() + { + BookmarksPaneModel.NextEnabledIndex(new[] { false, false }, selectedIndex: 0, delta: 1).Should().BeNull(); + } + + [Test] + public void Empty_list_yields_nothing() + { + BookmarksPaneModel.NextEnabledIndex(new bool[0], selectedIndex: -1, delta: 1).Should().BeNull(); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs new file mode 100644 index 0000000000..4685b2d012 --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs @@ -0,0 +1,243 @@ +// 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 Avalonia.Threading; +using Avalonia.VisualTree; + +using AvaloniaEdit.Rendering; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpy.Metadata; +using ICSharpCode.ILSpy.Metadata.CorTables; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +[TestFixture] +public class BookmarkNavigationViewTests +{ + // Regression for the bookmark hand-off when the active content is not a decompiler tab. + // Activating a bookmark from a metadata table (or Options / About) must still select the saved + // node, scroll its decompiled document to the bookmarked line, and play the one-shot highlight -- + // the pending bookmark has to reach the decompiler model that ends up displaying the node, not + // the (absent) currently-active decompiler tab. + [AvaloniaTest] + public async Task Navigating_From_NonDecompiler_Content_Scrolls_To_And_Highlights_The_Bookmark() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + + // Decompile System.Object and bookmark a line below the top so a scroll is observable. + var coreLibName = typeof(object).Assembly.GetName().Name!; + var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(objectNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var view = await window.WaitForComponent(); + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + + int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount) + .Where(view.CanToggleBookmarkAtLine) + .Skip(3) + .First(); + bookmarkLine.Should().BeGreaterThan(1, "the bookmark must sit below the top so the scroll is observable"); + int offset = view.Editor.Document.GetLineByNumber(bookmarkLine).Offset; + // Put the caret on the bookmarked line before toggling, as Ctrl+B / a gutter click would, so + // the bookmark's captured view state agrees with its anchor. + view.Editor.TextArea.Caret.Offset = offset; + AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.BookmarkToggle)) + .Execute(new TextViewContext { TextView = view, TextLocation = offset }); + Dispatcher.UIThread.RunJobs(); + manager.Bookmarks.Should().ContainSingle(); + var bookmark = manager.Bookmarks[0]; + + // Switch the active content to a metadata table: now there is no active decompiler tab. + var typeDefNode = vm.AssemblyTreeModel.FindCoreLib() + .GetChild() + .GetChild() + .GetChild(); + vm.AssemblyTreeModel.SelectNode(typeDefNode); + await vm.DockWorkspace.WaitForMetadataTabAsync(); + vm.DockWorkspace.ActiveDecompilerTab.Should().BeNull("the metadata table must be the active content"); + + // Activate the bookmark from there. + await AppComposition.Current.GetExport().NavigateToAsync(bookmark); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + view = await window.WaitForComponent(); + // Wait until the one-shot highlight has registered, then assert without any further delay: the + // adorner self-dismisses after an ~800 ms lifetime, so a fixed-length pump on a loaded CI runner + // can outlast it and observe an empty collection. The same deferred apply also lands the caret. + await Waiters.WaitForAsync(() => view.Editor.TextArea.TextView.BackgroundRenderers + .OfType().Any()); + + int targetLine = view.GetLineForBookmark(bookmark) ?? -1; + targetLine.Should().BeGreaterThan(1, "the bookmark resolves to a line below the top in the freshly shown document"); + + // P1: the caret/scroll landed on the bookmark's line rather than the default top. + view.Editor.TextArea.Caret.Line.Should().Be(targetLine, + "bookmark navigation from non-decompiler content must scroll to the saved line"); + + // P2: the one-shot line highlight is playing on the freshly shown view. + view.Editor.TextArea.TextView.BackgroundRenderers.OfType() + .Should().ContainSingle("the destination line must be highlighted after the content switch"); + } + + // Regression: when the active tab is frozen, navigating to a bookmark in a different node must + // open a fresh preview tab, decompile the node, and still scroll to + highlight the bookmark. + // This exercises the fresh-decompile hand-off (PendingBookmark consumed in the document-apply + // step), distinct from re-showing an already-decompiled node. + [AvaloniaTest] + public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Positions_A_Fresh_Preview() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + var coreLibName = typeof(object).Assembly.GetName().Name!; + + // Bookmark a line in System.String. + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var view = await window.WaitForComponent(); + await PumpLayoutAsync(); + + int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount) + .Where(view.CanToggleBookmarkAtLine) + .Skip(3) + .First(); + bookmarkLine.Should().BeGreaterThan(1); + int offset = view.Editor.Document.GetLineByNumber(bookmarkLine).Offset; + view.Editor.TextArea.Caret.Offset = offset; + AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.BookmarkToggle)) + .Execute(new TextViewContext { TextView = view, TextLocation = offset }); + Dispatcher.UIThread.RunJobs(); + var bookmark = manager.Bookmarks.Should().ContainSingle().Subject; + + // Show a different node, then freeze that tab so the next navigation must spawn a fresh preview. + var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object"); + vm.AssemblyTreeModel.SelectNode(objectNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + vm.DockWorkspace.FreezeCurrentTab(); + + // Activate the bookmark: a fresh preview tab must decompile System.String and land on the line. + await AppComposition.Current.GetExport().NavigateToAsync(bookmark); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + + var activeModel = vm.DockWorkspace.ActiveDecompilerTab; + activeModel.Should().NotBeNull("navigation must surface a decompiler tab"); + + // Wait until the fresh preview's view exists and its one-shot highlight has registered, then + // assert without any further delay: the adorner self-dismisses after an ~800 ms lifetime, so a + // fixed-length pump on a loaded CI runner can outlast it and observe an empty collection. + DecompilerTextView? ActiveView() => window.GetVisualDescendants().OfType() + .FirstOrDefault(v => ReferenceEquals(v.DataContext, activeModel)); + await Waiters.WaitForAsync(() => ActiveView()?.Editor.TextArea.TextView.BackgroundRenderers + .OfType().Any() == true); + var activeView = ActiveView()!; + + int targetLine = activeView.GetLineForBookmark(bookmark) ?? -1; + targetLine.Should().BeGreaterThan(1, "the fresh preview shows System.String with the bookmarked line below the top"); + activeView.Editor.TextArea.Caret.Line.Should().Be(targetLine, + "opening a fresh preview for a frozen-tab navigation must still scroll to the bookmark"); + activeView.Editor.TextArea.TextView.BackgroundRenderers.OfType() + .Should().ContainSingle("the destination line must be highlighted in the fresh preview"); + } + + // Regression: a bookmark re-anchors by token/IL offset, so a decompiler-setting change that + // reflows the text moves it to a different line than the one saved in its view state. Navigation + // must scroll to the re-resolved line; restoring the bookmark's stale saved caret/scroll instead + // would leave the bookmarked line off-screen with only the highlight playing where it can't be seen. + [AvaloniaTest] + public async Task Navigating_To_A_Bookmark_Scrolls_To_The_Resolved_Line_Not_The_Stale_Saved_Offset() + { + var (window, vm) = await TestHarness.BootAsync(3); + var manager = AppComposition.Current.GetExport(); + manager.Clear(); + var coreLibName = typeof(object).Assembly.GetName().Name!; + + // A long type so the bookmark can sit well below the first screenful. + var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String"); + vm.AssemblyTreeModel.SelectNode(stringNode); + await vm.DockWorkspace.WaitForDecompiledTextAsync(); + var view = await window.WaitForComponent(); + await PumpLayoutAsync(); + + var textView = view.Editor.TextArea.TextView; + textView.EnsureVisualLines(); + + // Bookmark a line well below the initial viewport. + int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount) + .Where(view.CanToggleBookmarkAtLine) + .FirstOrDefault(l => textView.GetVisualTopByDocumentLine(l) > textView.Bounds.Height * 1.5); + bookmarkLine.Should().BeGreaterThan(0, "need a bookmarkable line well below the first screenful"); + int offset = view.Editor.Document.GetLineByNumber(bookmarkLine).Offset; + view.Editor.TextArea.Caret.Offset = offset; + AppComposition.Current.GetExport() + .GetEntry(nameof(Resources.BookmarkToggle)) + .Execute(new TextViewContext { TextView = view, TextLocation = offset }); + Dispatcher.UIThread.RunJobs(); + var bookmark = manager.Bookmarks.Should().ContainSingle().Subject; + + // Simulate the post-reflow state: the bookmark still resolves (by token / IL offset) to its + // line, but the saved caret/scroll now point at the top of the document instead. + bookmark.ViewState.Should().NotBeNull(); + bookmark.ViewState = bookmark.ViewState! with { CaretOffset = 0, VerticalOffset = 0, HorizontalOffset = 0 }; + + // Navigate to the bookmark on the already-shown node. + vm.DockWorkspace.ActiveDecompilerTab!.ScrollToBookmark!.Invoke(bookmark); + await PumpLayoutAsync(); + + int targetLine = view.GetLineForBookmark(bookmark) ?? -1; + targetLine.Should().Be(bookmarkLine, "no real reflow happened, so the bookmark still resolves to its line"); + + view.Editor.TextArea.Caret.Line.Should().Be(targetLine, + "navigation must position by the re-resolved line, not the bookmark's stale saved caret"); + + double visualTop = textView.GetVisualTopByDocumentLine(targetLine); + visualTop.Should().BeInRange(textView.VerticalOffset, textView.VerticalOffset + textView.Bounds.Height, + "the bookmarked line must be on-screen after navigation, not scrolled away by the stale saved offset"); + } + + static async Task PumpLayoutAsync() + { + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs b/ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs new file mode 100644 index 0000000000..39dae06f5b --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs @@ -0,0 +1,79 @@ +// 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 Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.Views.Controls; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Bookmarks; + +[TestFixture] +public class BookmarksPaneStructureTests +{ + [AvaloniaTest] + public void Toolbar_uses_main_toolbar_chrome_and_button_content() + { + var pane = new BookmarksPane(); + var toolbarBorder = pane.FindControl("ToolbarBorder")!; + var toolbarRoot = pane.FindControl("ToolbarRoot")!; + + toolbarBorder.BorderThickness.Should().Be(new Avalonia.Thickness(0, 0, 0, 1)); + toolbarBorder.MinHeight.Should().Be(29); + toolbarBorder.Padding.Should().Be(new Avalonia.Thickness(3)); + toolbarRoot.Children.OfType().Should().HaveCount(2); + toolbarRoot.Children.OfType + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ILSpy/Bookmarks/BookmarksPane.axaml.cs b/ILSpy/Bookmarks/BookmarksPane.axaml.cs new file mode 100644 index 0000000000..4e7220f014 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarksPane.axaml.cs @@ -0,0 +1,43 @@ +// 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 Avalonia.Controls; +using Avalonia.Input; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + public partial class BookmarksPane : UserControl + { + public BookmarksPane() + { + InitializeComponent(); + BookmarkGrid.DoubleTapped += OnRowDoubleTapped; + } + + // Double-click anywhere on a row jumps to its location. The Name column stays editable + // through the DataGrid's own cell editing (it does not start on a double-tap). + void OnRowDoubleTapped(object? sender, TappedEventArgs e) + { + if (DataContext is BookmarksPaneModel model && BookmarkGrid.SelectedItem is Bookmark bookmark) + { + model.ActivateAsync(bookmark).HandleExceptions(); + e.Handled = true; + } + } + } +} diff --git a/ILSpy/Bookmarks/BookmarksPaneModel.cs b/ILSpy/Bookmarks/BookmarksPaneModel.cs new file mode 100644 index 0000000000..c226910aee --- /dev/null +++ b/ILSpy/Bookmarks/BookmarksPaneModel.cs @@ -0,0 +1,160 @@ +// 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.Collections.ObjectModel; +using System.Composition; +using System.Linq; +using System.Threading.Tasks; + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Commands; +using ICSharpCode.ILSpy.Docking; +using ICSharpCode.ILSpy.Properties; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.ViewModels; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// The dockable Bookmarks pane: a flat, editable list of bookmarks plus a toolbar of bookmark + /// actions. The list is the manager's live collection; navigation and per-document actions are + /// delegated to the navigator and to the active decompiler tab. + /// + [Export] + [ExportToolPane(ContentId = PaneContentId, Alignment = ToolPaneAlignment.Bottom, Order = 1, IsVisibleByDefault = false)] + [Shared] + public partial class BookmarksPaneModel : ToolPaneModel + { + public const string PaneContentId = "Bookmarks"; + + readonly BookmarkManager manager; + readonly BookmarkNavigator navigator; + + [ObservableProperty] + private Bookmark? selectedBookmark; + + [ImportingConstructor] + public BookmarksPaneModel(BookmarkManager manager, BookmarkNavigator navigator) + { + this.manager = manager; + this.navigator = navigator; + Id = PaneContentId; + Title = Resources.Bookmarks; + } + + /// The live bookmark list, bound by the grid. + public ObservableCollection Bookmarks => manager.Bookmarks; + + /// Double-click / Enter on a row: select it and jump to its location. + public Task ActivateAsync(Bookmark bookmark) + { + SelectedBookmark = bookmark; + return navigator.NavigateToAsync(bookmark); + } + + [RelayCommand] + void Next() => NavigateRelative(1); + + [RelayCommand] + void Previous() => NavigateRelative(-1); + + [RelayCommand] + void NextInFile() => ActiveTab?.NavigateBookmarkInFile?.Invoke(true); + + [RelayCommand] + void PreviousInFile() => ActiveTab?.NavigateBookmarkInFile?.Invoke(false); + + [RelayCommand] + void Delete() + { + if (SelectedBookmark is { } bookmark) + manager.Remove(bookmark); + } + + [RelayCommand] + void Disable() + { + if (SelectedBookmark is { } bookmark) + bookmark.Enabled = !bookmark.Enabled; + } + + [RelayCommand] + async Task Export() + { + var path = await FilePickers.SaveAsync(BookmarkFileFilter, "ILSpy.Bookmarks.json", Resources.BookmarkExportTitle); + if (path != null) + manager.Export(path); + } + + [RelayCommand] + async Task Import() + { + var path = await FilePickers.OpenAsync(BookmarkFileFilter, Resources.BookmarkImportTitle); + if (path == null) + return; + + BookmarkImportMode mode = BookmarkImportMode.Replace; + if (Bookmarks.Count > 0) + { + if (await BookmarkDialogs.AskImportModeAsync() is not { } chosen) + return; + mode = chosen; + } + if (!manager.Import(path, mode)) + await BookmarkDialogs.InformImportFailedAsync(); + } + + const string BookmarkFileFilter = "Bookmarks (*.json)|*.json|All Files|*.*"; + + static DecompilerTabPageModel? ActiveTab => AppComposition.TryGetExport()?.ActiveDecompilerTab; + + // Jumps to the next/previous ENABLED bookmark relative to the selected one, wrapping around. + // Disabled bookmarks remain in the list (and the gutter) but are skipped while stepping. + void NavigateRelative(int delta) + { + int index = SelectedBookmark != null ? Bookmarks.IndexOf(SelectedBookmark) : -1; + if (NextEnabledIndex(Bookmarks.Select(b => b.Enabled).ToList(), index, delta) is { } next) + ActivateAsync(Bookmarks[next]).HandleExceptions(); + } + + /// + /// The index of the next enabled item steps from + /// (skipping disabled, wrapping around), or null when no + /// enabled item exists. A negative (nothing selected) starts + /// just outside the list so the first step lands on the first / last candidate. + /// + internal static int? NextEnabledIndex(IReadOnlyList enabled, int selectedIndex, int delta) + { + int count = enabled.Count; + if (count == 0) + return null; + int start = selectedIndex >= 0 ? selectedIndex : (delta > 0 ? -1 : count); + for (int step = 1; step <= count; step++) + { + int candidate = ((start + delta * step) % count + count) % count; + if (enabled[candidate]) + return candidate; + } + return null; + } + } +} diff --git a/ILSpy/Bookmarks/MethodDebugInfo.cs b/ILSpy/Bookmarks/MethodDebugInfo.cs new file mode 100644 index 0000000000..9f045c4958 --- /dev/null +++ b/ILSpy/Bookmarks/MethodDebugInfo.cs @@ -0,0 +1,148 @@ +// 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; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// The IL-offset <-> text-line map for one decompiled method, built from the decompiler's + /// sequence points. A line that starts a statement maps to that statement's IL offset; the IL + /// offset is what a body bookmark stores, because it is stable when decompiler settings reflow + /// the C# text. Pure value type (ints/strings) so it can be built and tested without the decompiler. + /// + public sealed class MethodDebugInfo + { + // Each statement's first text line and its IL offset, kept in both orders for the two lookups. + readonly (int Line, int ILOffset)[] byLine; + readonly (int ILOffset, int Line)[] byOffset; + + public MethodDebugInfo(uint token, string fileName, string assemblyFullName, string moduleName, + string memberName, IEnumerable<(int Line, int ILOffset)> points) + { + Token = token; + FileName = fileName; + AssemblyFullName = assemblyFullName; + ModuleName = moduleName; + MemberName = memberName; + var list = points.ToList(); + byLine = list.OrderBy(p => p.Line).ThenBy(p => p.ILOffset).ToArray(); + byOffset = list.Select(p => (p.ILOffset, p.Line)).OrderBy(p => p.ILOffset).ToArray(); + } + + public uint Token { get; } + public string FileName { get; } + public string AssemblyFullName { get; } + public string ModuleName { get; } + public string MemberName { get; } + + /// The IL offset of the statement that starts on , if any. + public bool TryGetOffsetForLine(int line, out int ilOffset) + { + foreach (var p in byLine) + { + if (p.Line == line) + { + ilOffset = p.ILOffset; // byLine is ordered, so this is the lowest offset on the line + return true; + } + if (p.Line > line) + break; + } + ilOffset = 0; + return false; + } + + /// + /// The text line for : the statement at that exact offset, or the + /// last statement starting at or before it (so a body bookmark re-anchors even when a setting + /// change shifts where the statement lands). + /// + public bool TryGetLineForOffset(int ilOffset, out int line) + { + line = 0; + bool found = false; + foreach (var p in byOffset) + { + if (p.ILOffset > ilOffset) + break; + line = p.Line; + found = true; + } + // Nothing at or before the offset -> fall back to the first statement, if any. + if (!found && byOffset.Length > 0) + { + line = byOffset[0].Line; + found = true; + } + return found; + } + } + + /// + /// All per-method maps for one decompiled document, the bridge + /// between a clicked line and a token+IL-offset body anchor (and back). + /// + public sealed class DecompiledDebugInfo + { + public static readonly DecompiledDebugInfo Empty = new(new List()); + + readonly IReadOnlyList methods; + readonly Dictionary byToken; + + public DecompiledDebugInfo(IReadOnlyList methods) + { + this.methods = methods; + byToken = new Dictionary(); + foreach (var m in methods) + byToken[m.Token] = m; + } + + public IReadOnlyList Methods => methods; + + /// + /// Resolves a clicked to a body anchor when the line starts a + /// statement. Lines that don't (blank lines, lone braces) yield no body anchor; the caller + /// then falls back to a definition (token) anchor. + /// + public bool TryGetBodyAnchor(int line, out MethodDebugInfo method, out int ilOffset) + { + foreach (var m in methods) + { + if (m.TryGetOffsetForLine(line, out ilOffset)) + { + method = m; + return true; + } + } + method = null!; + ilOffset = 0; + return false; + } + + /// The text line for a stored body anchor, used to place the gutter icon and to scroll on navigation. + public bool TryGetLine(uint token, int ilOffset, out int line) + { + if (byToken.TryGetValue(token, out var m)) + return m.TryGetLineForOffset(ilOffset, out line); + line = 0; + return false; + } + } +} diff --git a/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs b/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs new file mode 100644 index 0000000000..b8d0558347 --- /dev/null +++ b/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs @@ -0,0 +1,46 @@ +// 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.Composition; + +using ICSharpCode.ILSpy.Properties; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// Right-click -> Toggle Bookmark in the decompiled C# view. Only shown on a line that can + /// hold a bookmark; the text view decides eligibility from the right-clicked text location. + /// + [ExportContextMenuEntry(Header = nameof(Resources.BookmarkToggle), Category = nameof(Resources.Editor), Order = 120, Icon = "Images/Bookmark")] + [Shared] + public sealed class ToggleBookmarkContextMenuEntry : IContextMenuEntry + { + public bool IsVisible(TextViewContext context) + => context.TextView is { } view + && context.TextLocation is { } offset + && view.CanToggleBookmarkAtOffset(offset); + + public bool IsEnabled(TextViewContext context) => true; + + public void Execute(TextViewContext context) + { + if (context.TextView is { } view && context.TextLocation is { } offset) + view.ToggleBookmarkAtOffset(offset); + } + } +} diff --git a/ILSpy/Commands/FilePickers.cs b/ILSpy/Commands/FilePickers.cs index 30cb54db68..5d4455332f 100644 --- a/ILSpy/Commands/FilePickers.cs +++ b/ILSpy/Commands/FilePickers.cs @@ -64,6 +64,25 @@ public static class FilePickers return file?.TryGetLocalPath(); } + /// + /// Shows an open-file picker for a single file. uses the same + /// WPF-style display|patterns syntax as . Returns the selected + /// absolute path, or null if the user cancelled. + /// + public static async Task OpenAsync(string filter, string? title = null) + { + var owner = UiContext.MainWindow; + if (owner == null) + return null; + + var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { + Title = title, + AllowMultiple = false, + FileTypeFilter = ParseFilter(filter), + }); + return files.Count == 0 ? null : files[0].TryGetLocalPath(); + } + /// /// Shows a folder-picker dialog. appears in the dialog /// chrome. Returns the selected folder's absolute path, or null if the user diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index 848a95dd19..07d0a3eb1e 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -131,22 +131,10 @@ public void PruneHistory(Predicate predicateOnNode) public void SaveLayout() => ILSpyDockFactory.SaveLayout(GetLayoutFilePath(), Layout); /// - /// Resolves ILSpy.Layout.json as a sidecar in the same directory the - /// XML ILSpy.xml settings file lives in — local-to-binary on portable - /// installs, %APPDATA%/ICSharpCode/ otherwise. Keeping it next to the XML - /// makes "delete settings to reset" still work as a single-folder action. - /// WPF stays XML; this is Avalonia-side only. + /// Resolves ILSpy.Layout.json as a sidecar next to the XML ILSpy.xml + /// settings file. WPF stays XML; this is Avalonia-side only. /// - static string GetLayoutFilePath() - { - var xmlPath = ICSharpCode.ILSpyX.Settings.ILSpySettings.SettingsFilePathProvider?.Invoke(); - if (string.IsNullOrEmpty(xmlPath)) - return "ILSpy.Layout.json"; - var dir = System.IO.Path.GetDirectoryName(xmlPath); - return string.IsNullOrEmpty(dir) - ? "ILSpy.Layout.json" - : System.IO.Path.Combine(dir, "ILSpy.Layout.json"); - } + static string GetLayoutFilePath() => AppEnv.ConfigurationFiles.GetPath("ILSpy.Layout.json"); public IReadOnlyList ToolPaneMenuItems { get; } @@ -697,6 +685,13 @@ void OnNavigateRequested(ReferenceSegment segment) // Created lazily on first need. DecompilerTabPageModel? decompilerContent; + // A bookmark whose line the next tree-node decompile should scroll to and highlight. Set by + // NavigateToBookmark before the node is selected, and consumed in ShowSelectedNode when the + // selection routes to the decompiler content -- so a bookmark activated while a metadata + // table, the Options page, or the About page is the active content still positions correctly, + // even though there is no active decompiler tab to hand it to directly. + Bookmarks.Bookmark? pendingBookmark; + // The startup welcome page (About content in the reusable MainTab, non-static). Tracked so // Help > About can activate it instead of spawning a duplicate static About tab while it is // still on screen. Self-correcting: once a tree-node selection swaps MainTab.Content to the @@ -748,6 +743,33 @@ public void RefreshDecompilerOutputInPlace() } } + /// + /// Selects and scrolls the decompiler view to 's + /// line once that node's document and IL-offset map have landed. Unlike setting + /// on , + /// this works when the currently active content is not a decompiler tab (a metadata table, the + /// Options page, the About page): the bookmark is handed to the decompiler model that + /// routes the selection to. + /// + public void NavigateToBookmark(ICSharpCode.ILSpyX.TreeView.SharpTreeNode node, Bookmarks.Bookmark bookmark) + { + pendingBookmark = bookmark; + var previousSelection = assemblyTreeModel.SelectedItem; + assemblyTreeModel.SelectNode(node); + // Selecting an already-selected node is a no-op, so ShowSelectedNode never runs to consume + // the pending bookmark. When the node is already the active decompiler content, position + // against the live tab directly; otherwise drop the pending bookmark so it cannot bleed + // into a later navigation. + if (pendingBookmark is { } pending && ReferenceEquals(previousSelection, node)) + { + pendingBookmark = null; + // The document is already displayed, so no document-apply step will run to consume a + // PendingBookmark -- scroll the live view directly instead. + if (ActiveDecompilerTab is { } tab) + tab.ScrollToBookmark?.Invoke(pending); + } + } + void ShowSelectedNode() { using var _ = ICSharpCode.ILSpy.AppEnv.AppLog.Phase("DockWorkspace.ShowSelectedNode"); @@ -807,6 +829,19 @@ void ShowSelectedNode() using (ICSharpCode.ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: main.Content = decTab (Dock view-recycling)")) main.Content = decTab; decTab.Language = languageService.CurrentLanguage; + // Carry a bookmark navigation onto the model that will display the node, before the + // decompile starts; the text view positions the caret + highlight once the document lands. + if (pendingBookmark is { } bookmark) + { + pendingBookmark = null; + // When the node is already decompiled in this tab (re-shown after a metadata/Options/ + // About interlude), no document-apply step runs to consume a PendingBookmark, so scroll + // the live view directly. Otherwise the upcoming decompile's apply step consumes it. + if (decTab.CurrentNodes.SequenceEqual(nodes)) + decTab.ScrollToBookmark?.Invoke(bookmark); + else + decTab.PendingBookmark = bookmark; + } using (ICSharpCode.ILSpy.AppEnv.AppLog.Phase("ShowSelectedNode: decTab.CurrentNodes = nodes (kicks off DecompileAsync)")) decTab.CurrentNodes = nodes; main.SourceNode = nodes.Length == 1 ? nodes[0] : null; diff --git a/ILSpy/Images.cs b/ILSpy/Images.cs index 7abd585972..b3508dc681 100644 --- a/ILSpy/Images.cs +++ b/ILSpy/Images.cs @@ -86,6 +86,15 @@ static IImage LoadPng(string name) public static readonly IImage ShowPrivateInternal = LoadSvg(nameof(ShowPrivateInternal)); public static readonly IImage ShowAll = LoadSvg(nameof(ShowAll)); + // Bookmarks (file names carry dots, so the dotted variants can't use nameof). + public static readonly IImage Bookmark = LoadSvg(nameof(Bookmark)); + public static readonly IImage BookmarkDisable = LoadSvg("Bookmark.Disable"); + public static readonly IImage BookmarkNext = LoadSvg("Bookmark.Next"); + public static readonly IImage BookmarkPrevious = LoadSvg("Bookmark.Previous"); + public static readonly IImage BookmarkNextInFile = LoadSvg("Bookmark.Next.File"); + public static readonly IImage BookmarkPreviousInFile = LoadSvg("Bookmark.Previous.File"); + public static readonly IImage BookmarkClear = LoadSvg("Bookmark.Clear"); + // Type-relation tree nodes (Base Types / Derived Types). public static readonly IImage SuperTypes = LoadSvg(nameof(SuperTypes)); public static readonly IImage SubTypes = LoadSvg(nameof(SubTypes)); diff --git a/ILSpy/Languages/CSharpLanguage.cs b/ILSpy/Languages/CSharpLanguage.cs index b777e31829..73a2fcc0a1 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -345,11 +345,11 @@ public override void DecompileMethod(IMethod method, ITextOutput output, Decompi { var members = CollectFieldsAndCtors(methodDefinition.DeclaringTypeDefinition!, methodDefinition.IsStatic); decompiler.AstTransforms.Add(new SelectCtorTransform(methodDefinition)); - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(members), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(members)); } else { - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(method.MetadataToken), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(method.MetadataToken)); } OnCSharpDecompiled(output, options); } @@ -362,7 +362,7 @@ public override void DecompileProperty(IProperty property, ITextOutput output, D { CSharpDecompiler decompiler = BeginDecompile(property, output, options); WriteCommentLine(output, TypeToString(property.DeclaringType)); - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(property.MetadataToken), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(property.MetadataToken)); OnCSharpDecompiled(output, options); } @@ -372,14 +372,14 @@ public override void DecompileField(IField field, ITextOutput output, Decompilat WriteCommentLine(output, TypeToString(field.DeclaringType)); if (field.IsConst) { - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(field.MetadataToken), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(field.MetadataToken)); } else { var members = CollectFieldsAndCtors(field.DeclaringTypeDefinition!, field.IsStatic); var resolvedField = decompiler.TypeSystem.MainModule.GetDefinition((FieldDefinitionHandle)field.MetadataToken); decompiler.AstTransforms.Add(new SelectFieldTransform(resolvedField)); - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(members), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(members)); } OnCSharpDecompiled(output, options); } @@ -408,7 +408,7 @@ void DecompileExtensionCore(IEntity extension, IType commentType, ITextOutput ou CSharpDecompiler decompiler = BeginDecompile(extension, output, options); WriteCommentLine(output, TypeToString(commentType, ConversionFlags.UseFullyQualifiedTypeNames | ConversionFlags.UseFullyQualifiedEntityNames | ConversionFlags.SupportExtensionDeclarations)); - WriteCode(output, options.DecompilerSettings, decompiler.DecompileExtension(extension.MetadataToken), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.DecompileExtension(extension.MetadataToken)); OnCSharpDecompiled(output, options); } @@ -416,7 +416,7 @@ public override void DecompileEvent(IEvent ev, ITextOutput output, Decompilation { CSharpDecompiler decompiler = BeginDecompile(ev, output, options); WriteCommentLine(output, TypeToString(ev.DeclaringType)); - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(ev.MetadataToken), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(ev.MetadataToken)); OnCSharpDecompiled(output, options); } @@ -424,7 +424,7 @@ public override void DecompileType(ITypeDefinition type, ITextOutput output, Dec { CSharpDecompiler decompiler = BeginDecompile(type, output, options); WriteCommentLine(output, TypeToString(type, ConversionFlags.UseFullyQualifiedTypeNames | ConversionFlags.UseFullyQualifiedEntityNames)); - WriteCode(output, options.DecompilerSettings, decompiler.Decompile(type.MetadataToken), decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(type.MetadataToken)); OnCSharpDecompiled(output, options); } @@ -511,7 +511,7 @@ public override void DecompileType(ITypeDefinition type, ITextOutput output, Dec SyntaxTree st = options.FullDecompilation ? decompiler.DecompileWholeModuleAsSingleFile() : decompiler.DecompileModuleAndAssemblyAttributes(); - WriteCode(output, options.DecompilerSettings, st, decompiler.TypeSystem); + WriteCode(output, options.DecompilerSettings, decompiler, st); return null; } @@ -709,14 +709,25 @@ public void Run(AstNode rootNode, TransformContext context) } } - static void WriteCode(ITextOutput output, DecompilerSettings settings, SyntaxTree syntaxTree, IDecompilerTypeSystem typeSystem) + static void WriteCode(ITextOutput output, DecompilerSettings settings, CSharpDecompiler decompiler, SyntaxTree syntaxTree) { syntaxTree.AcceptVisitor(new InsertParenthesesVisitor { InsertParenthesesForReadability = true }); output.IndentationString = settings.CSharpFormattingOptions.IndentationString; - TokenWriter tokenWriter = new TextTokenWriter(output, settings, typeSystem); + + TokenWriter tokenWriter = new TextTokenWriter(output, settings, decompiler.TypeSystem); if (output is TextView.ISmartTextOutput smartOutput) tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, smartOutput); + + // For the on-screen C# view, harvest the IL-offset/line map for body bookmarks during this + // single formatting pass (see Bookmarks.BookmarkDebugInfoCollector). The collector is the + // outermost writer so its StartNode sees each node's start line before any token is written. + // Other outputs (IL view, ilspycmd's plain text) are not AvaloniaEditTextOutput and are unaffected. + Bookmarks.BookmarkDebugInfoCollector? bookmarkCollector = null; + if (output is TextView.AvaloniaEditTextOutput bookmarkOutput) + tokenWriter = bookmarkCollector = new Bookmarks.BookmarkDebugInfoCollector(tokenWriter, bookmarkOutput); + syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions)); + bookmarkCollector?.Publish(); } void AddWarningMessage(MetadataFile module, ITextOutput output, string line1, string? line2 = null, diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index 11f9cb5cf9..26cf421960 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // Runtime Version:4.0.30319.42000 @@ -440,7 +440,169 @@ public static string BaseTypes { return ResourceManager.GetString("BaseTypes", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Assembly for this bookmark no longer exists on disk, remove bookmark?. + /// + public static string BookmarkAssemblyMissing { + get { + return ResourceManager.GetString("BookmarkAssemblyMissing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Delete Bookmark. + /// + public static string BookmarkDelete { + get { + return ResourceManager.GetString("BookmarkDelete", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Enable / Disable Bookmark. + /// + public static string BookmarkDisable { + get { + return ResourceManager.GetString("BookmarkDisable", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Export Bookmarks.... + /// + public static string BookmarkExport { + get { + return ResourceManager.GetString("BookmarkExport", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Export Bookmarks. + /// + public static string BookmarkExportTitle { + get { + return ResourceManager.GetString("BookmarkExportTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Import Bookmarks.... + /// + public static string BookmarkImport { + get { + return ResourceManager.GetString("BookmarkImport", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The selected file could not be read as a bookmark file. Your bookmarks were left unchanged.. + /// + public static string BookmarkImportFailed { + get { + return ResourceManager.GetString("BookmarkImportFailed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Merge. + /// + public static string BookmarkImportMerge { + get { + return ResourceManager.GetString("BookmarkImportMerge", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Replace. + /// + public static string BookmarkImportReplace { + get { + return ResourceManager.GetString("BookmarkImportReplace", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Replace the current bookmarks with the imported ones, or merge them?. + /// + public static string BookmarkImportReplaceOrMerge { + get { + return ResourceManager.GetString("BookmarkImportReplaceOrMerge", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Import Bookmarks. + /// + public static string BookmarkImportTitle { + get { + return ResourceManager.GetString("BookmarkImportTitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Module. + /// + public static string BookmarkModule { + get { + return ResourceManager.GetString("BookmarkModule", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next Bookmark. + /// + public static string BookmarkNextBookmark { + get { + return ResourceManager.GetString("BookmarkNextBookmark", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Next Bookmark in File. + /// + public static string BookmarkNextInFile { + get { + return ResourceManager.GetString("BookmarkNextInFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Previous Bookmark. + /// + public static string BookmarkPreviousBookmark { + get { + return ResourceManager.GetString("BookmarkPreviousBookmark", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Previous Bookmark in File. + /// + public static string BookmarkPreviousInFile { + get { + return ResourceManager.GetString("BookmarkPreviousInFile", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Bookmarks. + /// + public static string Bookmarks { + get { + return ResourceManager.GetString("Bookmarks", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Toggle Bookmark. + /// + public static string BookmarkToggle { + get { + return ResourceManager.GetString("BookmarkToggle", resourceCulture); + } + } + /// /// Looks up a localized string similar to C_lone. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 80ff70b621..16b6051ce4 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -165,6 +165,60 @@ Are you sure you want to continue? Base Types + + Assembly for this bookmark no longer exists on disk, remove bookmark? + + + Delete Bookmark + + + Enable / Disable Bookmark + + + Export Bookmarks... + + + Export Bookmarks + + + Import Bookmarks... + + + The selected file could not be read as a bookmark file. Your bookmarks were left unchanged. + + + Merge + + + Replace + + + Replace the current bookmarks with the imported ones, or merge them? + + + Import Bookmarks + + + Module + + + Next Bookmark + + + Next Bookmark in File + + + Previous Bookmark + + + Previous Bookmark in File + + + Toggle Bookmark + + + Bookmarks + C_lone diff --git a/ILSpy/TextView/AvaloniaEditTextOutput.cs b/ILSpy/TextView/AvaloniaEditTextOutput.cs index 64611b91e1..9c04fcf48c 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -60,6 +60,10 @@ public sealed class AvaloniaEditTextOutput : ISmartTextOutput bool needsIndent; int lineNumber = 1; + /// The 1-based line the next written text lands on; used to map captured + /// sequence-point lines (relative to a code block) to absolute document lines. + public int CurrentLine => lineNumber; + public RichTextModel HighlightingModel { get; } = new RichTextModel(); // The same spans that build HighlightingModel, but holding the SHARED named @@ -83,6 +87,18 @@ public sealed class AvaloniaEditTextOutput : ISmartTextOutput /// Maps reference targets to their definition offsets in the rendered text. public DefinitionLookup DefinitionLookup { get; } = new(); + readonly List methodDebugInfos = new(); + + /// + /// Per-method IL-offset <-> line maps captured during a C# decompile (see + /// ). Empty for non-C# output; used to anchor + /// in-method bookmarks by IL offset instead of a fragile line number. + /// + public IReadOnlyList MethodDebugInfos => methodDebugInfos; + + /// Appends a captured method map; called by the C# language after writing the code. + public void AddMethodDebugInfo(Bookmarks.MethodDebugInfo info) => methodDebugInfos.Add(info); + readonly List>> uiElements = new(); /// Inline UI elements collected during writing, in offset order. Fed to diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index bfd487e5e8..397dff4b42 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -183,6 +183,14 @@ void ResetProgress() [ObservableProperty] private DefinitionLookup? definitionLookup; + /// + /// IL-offset <-> line maps for the methods in this document (C# only). Lets bookmarks + /// anchor in-method lines by IL offset and the gutter place their icons. Null for non-C# + /// content; when C# yielded no methods. + /// + [ObservableProperty] + private Bookmarks.DecompiledDebugInfo? debugInfo; + /// /// Inline UI elements (), in offset order. /// Fed to by the text view. @@ -229,6 +237,26 @@ void ResetProgress() /// public DecompilerTextViewState? PendingViewState { get; set; } + /// + /// A bookmark to scroll to the next time this document is (re)applied. Set by bookmark + /// navigation before the target node is decompiled; the text view reads it in its + /// document-apply step, computes the line from the saved token, positions the caret and plays + /// the highlight, then clears it. Consumed there -- alongside the view-state restore -- rather + /// than reacting to a property change, so the reset-to-top of a fresh navigation cannot scroll + /// the bookmark position away in a later pass. + /// + public Bookmarks.Bookmark? PendingBookmark { get; set; } + + /// + /// Scrolls the live document to a bookmark immediately, for navigation that lands on the + /// already-displayed node (no re-decompile, so no document-apply step runs). Set by the text + /// view; mirrors . + /// + public System.Action? ScrollToBookmark { get; set; } + + /// Moves to the next (true) / previous (false) bookmark within this document. Set by the text view. + public System.Action? NavigateBookmarkInFile { get; set; } + /// /// Fired when the user clicks a cross-document reference. The host (DockWorkspace) /// resolves the target on the assembly tree side. @@ -482,6 +510,7 @@ async Task DecompileAsync() Foldings = null; References = null; DefinitionLookup = null; + DebugInfo = null; UIElements = null; Text = string.Empty; IsDecompiling = false; @@ -711,6 +740,11 @@ void ApplyOutput(AvaloniaEditTextOutput output, string syntaxExtension, string t Foldings = output.Foldings; References = output.References; DefinitionLookup = output.DefinitionLookup; + DebugInfo = syntaxExtension != ".cs" + ? null + : output.MethodDebugInfos.Count > 0 + ? new Bookmarks.DecompiledDebugInfo(output.MethodDebugInfos) + : Bookmarks.DecompiledDebugInfo.Empty; UIElements = output.UIElements; Text = text; } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 85a4a8dd57..54eb45e70f 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -33,6 +33,7 @@ using Avalonia.Threading; using Avalonia.VisualTree; +using AvaloniaEdit.Document; using AvaloniaEdit.Folding; using AvaloniaEdit.Highlighting; @@ -43,10 +44,13 @@ using ICSharpCode.Decompiler.Output; using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.TreeView; using ICSharpCode.ILSpy; using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.Options; +using ICSharpCode.ILSpy.TreeNodes; namespace ICSharpCode.ILSpy.TextView { @@ -93,6 +97,7 @@ public partial class DecompilerTextView : UserControl // text. Position-relative menu entries (e.g. "Toggle folding") read this so they act on the // clicked line rather than wherever the caret happens to sit. int? lastRightClickedOffset; + Bookmarks.BookmarkMargin? bookmarkMargin; IReadOnlyList contextMenuEntries = Array.Empty(); Popup richPopup = null!; double distanceToPopupLimit; @@ -138,6 +143,29 @@ public DecompilerTextView() // Ctrl+L focuses the omnibar into search mode (browser address-bar gesture). Tunnel so // it wins before AvaloniaEdit's own key handling while focus is anywhere in the editor. AddHandler(KeyDownEvent, OnPreviewKeyDownForOmnibar, RoutingStrategies.Tunnel); + + // Ctrl+B toggles a bookmark on the caret line. Bubble so normal editor keys keep working. + AddHandler(KeyDownEvent, OnBookmarkKeyDown, RoutingStrategies.Bubble); + + // Bookmark icon gutter, leftmost (before the line numbers). It draws nothing unless the + // current document is C# and has bookmarks, so it is harmless on IL / metadata views. + bookmarkMargin = new Bookmarks.BookmarkMargin(this); + Editor.TextArea.LeftMargins.Insert(0, bookmarkMargin); + + // The text area paints the I-beam across its whole surface and the gutter margins inherit it + // by walking up the visual tree. The left margins (bookmark gutter, line numbers, folding) are + // click targets, not text, so force the normal arrow there. Line numbers and folding come and + // go as their display settings toggle, so re-apply whenever the margin collection changes. + Editor.TextArea.LeftMargins.CollectionChanged += (_, _) => ApplyArrowCursorToLeftMargins(); + ApplyArrowCursorToLeftMargins(); + } + + internal static readonly Cursor ArrowCursor = new(StandardCursorType.Arrow); + + void ApplyArrowCursorToLeftMargins() + { + foreach (var margin in Editor.TextArea.LeftMargins) + margin.Cursor = ArrowCursor; } void OnPreviewKeyDownForOmnibar(object? sender, KeyEventArgs e) @@ -648,6 +676,269 @@ void OnContextMenuOpening(object? sender, CancelEventArgs e) } } + #region Bookmarks + + Bookmarks.BookmarkManager? bookmarkManager; + + Bookmarks.BookmarkManager? BookmarkManager + => bookmarkManager ??= AppEnv.AppComposition.TryGetExport(); + + // Bookmarks live only on the decompiled C# view, not on IL / metadata / resource output. + bool ShowsBookmarkableCode => DataContext is DecompilerTabPageModel { SyntaxExtension: ".cs" }; + + // Just the anchor for a line, or null when the line can't be bookmarked. Cheap enough to call + // per hovered line: it does not capture the editor view state (foldings, scroll, tree path), + // which only a bookmark that is actually being created needs. + Bookmarks.Bookmark? CreateAnchorForLine(int line) + { + if (!ShowsBookmarkableCode || DataContext is not DecompilerTabPageModel model) + return null; + var fallbackOwner = model.CurrentNode is IMemberTreeNode memberNode ? memberNode.Member : null; + return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line, + fallbackOwner, GetLocationNodeName(model.CurrentNode)); + } + + Bookmarks.Bookmark? CreateBookmarkForLine(int line) + { + var bookmark = CreateAnchorForLine(line); + if (bookmark != null && DataContext is DecompilerTabPageModel model) + { + var displaySettings = AppComposition.TryGetExport()?.DisplaySettings; + var selectedTreeNodePath = AssemblyTreeModel.GetPathForNode(model.CurrentNode); + bookmark.ViewState = Bookmarks.BookmarkViewState.From(GetCurrentViewState(), displaySettings, selectedTreeNodePath); + } + return bookmark; + } + + static string? GetLocationNodeName(SharpTreeNode? node) + => node is IMemberTreeNode { Member: { } member } ? member.FullName : node?.ToString(); + + /// Whether can hold a bookmark in the current document. + internal bool CanToggleBookmarkAtLine(int line) => CreateAnchorForLine(line) != null; + + internal bool CanToggleBookmarkAtOffset(int offset) + { + if (offset < 0 || offset > Editor.Document.TextLength) + return false; + return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); + } + + /// + /// Adds or removes a bookmark on ; a no-op for non-anchorable lines. + /// Returns true when a bookmark was added, false when one was removed or the line is not anchorable. + /// + internal bool ToggleBookmarkAtLine(int line) + { + if (CreateBookmarkForLine(line) is { } candidate) + return BookmarkManager?.Toggle(candidate) ?? false; + return false; + } + + internal void ToggleBookmarkAtOffset(int offset) + { + if (offset < 0 || offset > Editor.Document.TextLength) + return; + ToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); + } + + void OnBookmarkKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.B && e.KeyModifiers == KeyModifiers.Control && Editor.TextArea.IsKeyboardFocusWithin) + { + ToggleBookmarkAtLine(Editor.TextArea.Caret.Line); + e.Handled = true; + } + } + + // Moves the caret to the next/previous bookmark in this document, ordered by line and relative + // to the caret (wrapping around). A no-op when the document holds fewer than one bookmark. + void NavigateBookmarkInFile(bool forward) + { + if (BookmarkManager is not { } manager) + return; + var lines = new List(); + foreach (var bookmark in manager.Bookmarks) + { + // Disabled bookmarks stay visible in the gutter but are skipped by next/previous. + if (bookmark.Enabled && GetLineForBookmark(bookmark) is { } line) + lines.Add(line); + } + if (lines.Count == 0) + return; + lines.Sort(); + int caretLine = Editor.TextArea.Caret.Line; + int target = forward + ? lines.FirstOrDefault(l => l > caretLine, lines[0]) + : lines.LastOrDefault(l => l < caretLine, lines[^1]); + ScrollToLine(target); + } + + void ApplyPendingBookmark(DecompilerTabPageModel model) + { + // Clear only once the line actually resolves and we scroll. A document-apply can run + // against the not-yet-decompiled document (a fresh preview tab binds with empty text + // before its decompile lands); leaving PendingBookmark set there lets the apply that + // follows the real content position the caret instead of discarding the navigation. + if (model.PendingBookmark is { } bookmark && ApplyBookmark(bookmark)) + model.PendingBookmark = null; + } + + // Computes the bookmark's current line from its saved token/anchor (resilient to reflow) and + // scrolls there with the one-shot highlight. Returns false when the line cannot be resolved + // yet (document not decompiled, or not C#). Shared by the document-apply consumption of + // PendingBookmark and the ScrollToBookmark callback for an already-displayed node. + bool ApplyBookmark(Bookmarks.Bookmark bookmark) + { + if (GetLineForBookmark(bookmark) is { } line) + { + ScrollToLine(line, bookmark.ViewState); + return true; + } + return false; + } + + void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) + { + var document = Editor.Document; + line = Math.Clamp(line, 1, document.LineCount); + Editor.TextArea.Caret.Offset = document.GetLineByNumber(line).Offset; + // Centre the line and pulse its gutter icon once the layout has caught up. Posting lets a + // just-applied document finish measuring, so the visual position is accurate either way -- + // whether we got here after a fresh decompile or while the document was already on screen. + Dispatcher.UIThread.Post(() => { + if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount) + return; + // Restore the captured foldings first -- collapsing/expanding shifts where lines sit -- + // then centre the re-resolved line. The bookmark's saved caret/scroll are deliberately + // not restored: a decompiler-setting change can reflow the text so the bookmark + // re-anchors to a different line, and the stale offset would scroll it back off-screen. + if (viewState != null) + RestoreBookmarkFoldings(viewState); + CenterLineInView(document, line); + LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line); + bookmarkMargin?.PulseLine(line); + }, DispatcherPriority.Background); + } + + void RestoreBookmarkFoldings(Bookmarks.BookmarkViewState viewState) + { + if (viewState.ToDecompilerTextViewState().Foldings is { } saved && activeFoldingManager is { } manager) + FoldingsViewState.Restore(manager.AllFoldings, saved); + } + + // Scrolls so sits in the middle of the viewport. AvaloniaEdit's + // ScrollTo* are no-ops in 12.0.0 (#594), so set the ScrollViewer offset directly. + void CenterLineInView(TextDocument document, int line) + { + if (EditorScrollViewer is not { } scrollViewer) + return; + if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount) + return; + var textView = Editor.TextArea.TextView; + if (!ReferenceEquals(textView.Document, document)) + return; + double visualTop = textView.GetVisualTopByDocumentLine(line); + double target = visualTop - (scrollViewer.Viewport.Height - textView.DefaultLineHeight) / 2; + scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Max(0, target)); + } + + // The identity of the inputs that decide where bookmarks resolve in the current C# document. + // Both are replaced on every decompile, while the editor reuses one TextDocument (only its text + // changes), so this -- not the document reference -- is what tells the gutter its cache is stale. + internal (object? DebugInfo, object? References) BookmarkContentVersion + => DataContext is DecompilerTabPageModel { SyntaxExtension: ".cs" } model + ? (model.DebugInfo, model.References) + : default; + + /// + /// The document line a bookmark sits on in the currently shown C# document, or null when the + /// bookmark belongs to other code. Body anchors resolve via the IL-offset map; token anchors + /// via the definition's position. The module identity is verified so a token value shared + /// across assemblies can't place an icon on the wrong line. + /// + internal int? GetLineForBookmark(Bookmarks.Bookmark bookmark, bool updateRenderedLine = true) + { + if (DataContext is not DecompilerTabPageModel { SyntaxExtension: ".cs" } model) + return null; + + if (bookmark.Kind == Bookmarks.BookmarkKind.Body) + { + if (model.DebugInfo is not { } debug) + return null; + foreach (var method in debug.Methods) + { + if (MatchesBookmark(method, bookmark) + && method.TryGetLineForOffset(bookmark.ILOffset, out var bodyLine)) + { + if (updateRenderedLine) + bookmark.UpdateRenderedLineNumber(bodyLine); + return bodyLine; + } + } + return null; + } + + if (bookmark.Kind == Bookmarks.BookmarkKind.Line) + { + if (bookmark.LineNumber < 1 || bookmark.LineNumber > Editor.Document.LineCount) + return null; + if (DocumentContainsBookmarkToken(model, bookmark)) + return bookmark.LineNumber; + return null; + } + + if (model.References is { } references) + { + foreach (var segment in references) + { + if (segment.IsDefinition && segment.Reference is IEntity entity && MatchesBookmark(entity, bookmark)) + { + var line = Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber; + if (updateRenderedLine) + bookmark.UpdateRenderedLineNumber(line); + return line; + } + } + } + return null; + } + + // The token + assembly identity that ties a bookmark to a member in the current document. The + // module identity is checked too, so a token value shared across assemblies can't match the + // wrong member. Both forms (the body-anchor debug map and a reference segment's entity) are + // kept in step here rather than spelled out at each call site. + static bool MatchesBookmark(Bookmarks.MethodDebugInfo method, Bookmarks.Bookmark bookmark) + => method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName; + + static bool MatchesBookmark(IEntity entity, Bookmarks.Bookmark bookmark) + => (uint)MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token + && entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName; + + static bool DocumentContainsBookmarkToken(DecompilerTabPageModel model, Bookmarks.Bookmark bookmark) + { + if (model.DebugInfo != null) + { + foreach (var method in model.DebugInfo.Methods) + { + if (MatchesBookmark(method, bookmark)) + return true; + } + } + + if (model.References != null) + { + foreach (var segment in model.References) + { + if (segment.Reference is IEntity entity && MatchesBookmark(entity, bookmark)) + return true; + } + } + + return false; + } + + #endregion + protected override void OnDataContextChanged(System.EventArgs e) { base.OnDataContextChanged(e); @@ -658,6 +949,8 @@ protected override void OnDataContextChanged(System.EventArgs e) { previous.PropertyChanged -= OnModelPropertyChanged; previous.CaptureViewState = null; + previous.NavigateBookmarkInFile = null; + previous.ScrollToBookmark = null; } boundModel = DataContext as DecompilerTabPageModel; @@ -668,6 +961,11 @@ protected override void OnDataContextChanged(System.EventArgs e) // records a navigation away. (Re)assigning every DataContext-change handles both // the first attach and an ABA reattach. model.CaptureViewState = GetCurrentViewState; + // Bookmarks-pane toolbar navigation actions that operate on the active document route through this. + model.NavigateBookmarkInFile = NavigateBookmarkInFile; + // Direct scroll for a bookmark activated on the already-displayed node, where no + // document-apply step runs to consume PendingBookmark. + model.ScrollToBookmark = bookmark => ApplyBookmark(bookmark); ApplyDocument(model); // Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the // node rather than letting it inherit the document DataContext). @@ -774,6 +1072,13 @@ void ApplyDocument(DecompilerTabPageModel model, bool restoreViewState = true) if (restoreViewState) RestoreOrResetViewState(pendingState); + // Position at a navigated-to bookmark once its document (and debug map) has landed. Only on + // the final content (the Text change, where restoreViewState is set), AFTER the view-state + // reset above, so the bookmark scroll is the last word on position and the highlight plays + // on the settled layout instead of an intermediate render a later rebuild would scroll away. + if (restoreViewState) + ApplyPendingBookmark(model); + SwapCustomElementGenerators(model.CustomElementGenerators); Editor.TextArea.TextView.Redraw(); diff --git a/ILSpy/TextView/FoldingsViewState.cs b/ILSpy/TextView/FoldingsViewState.cs index 10be9a64f6..a1941e5369 100644 --- a/ILSpy/TextView/FoldingsViewState.cs +++ b/ILSpy/TextView/FoldingsViewState.cs @@ -88,19 +88,33 @@ public static bool Restore(IList newFoldings, Snapshot saved) if (checksum != saved.Checksum) return false; foreach (var folding in newFoldings) + folding.DefaultClosed = !IsExpanded(saved, folding.StartOffset, folding.EndOffset); + return true; + } + + /// + /// Applies to live already installed in a + /// FoldingManager by toggling . Returns true when the + /// layout still matches the snapshot; false (leaving the foldings untouched) on a mismatch. + /// + public static bool Restore(IEnumerable foldings, Snapshot saved) + { + var sections = foldings as IReadOnlyList ?? foldings.ToList(); + if (Capture(sections).Checksum != saved.Checksum) + return false; + foreach (var folding in sections) + folding.IsFolded = !IsExpanded(saved, folding.StartOffset, folding.EndOffset); + return true; + } + + static bool IsExpanded(Snapshot saved, int start, int end) + { + foreach (var (s, e) in saved.Expanded) { - bool wasExpanded = false; - foreach (var (start, end) in saved.Expanded) - { - if (start == folding.StartOffset && end == folding.EndOffset) - { - wasExpanded = true; - break; - } - } - folding.DefaultClosed = !wasExpanded; + if (s == start && e == end) + return true; } - return true; + return false; } } } diff --git a/ILSpy/TextView/LineHighlightAdorner.cs b/ILSpy/TextView/LineHighlightAdorner.cs new file mode 100644 index 0000000000..a205fc04a3 --- /dev/null +++ b/ILSpy/TextView/LineHighlightAdorner.cs @@ -0,0 +1,112 @@ +// 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; +using System.Diagnostics; +using System.Linq; + +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +using global::Avalonia; +using global::Avalonia.Media; +using global::Avalonia.Threading; + +namespace ICSharpCode.ILSpy.TextView +{ + /// + /// A brief full-width highlight across a single line, played after a bookmark navigation so the + /// destination line stands out even when several bookmarks sit close together. The translucent + /// fill holds briefly, then fades over the remainder of an ~800 ms lifetime. Drawn on the + /// selection layer so it sits behind the text and leaves it readable. + /// + public sealed class LineHighlightAdorner : IBackgroundRenderer + { + const int LifetimeMs = 800; + const int HoldMs = 150; + + // Warm amber that reads on both light and dark themes; opacity is animated on top of this. + static readonly IBrush Fill = new SolidColorBrush(Color.FromArgb(0x70, 0xC2, 0x7D, 0x1A)).ToImmutable(); + + readonly TextArea textArea; + readonly int line; + readonly Stopwatch elapsed = Stopwatch.StartNew(); + readonly DispatcherTimer frameTimer; + readonly DispatcherTimer lifetimeTimer; + + LineHighlightAdorner(TextArea textArea, int line) + { + this.textArea = textArea; + this.line = line; + frameTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + frameTimer.Tick += (_, _) => textArea.TextView.InvalidateLayer(KnownLayer.Selection); + lifetimeTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(LifetimeMs) }; + lifetimeTimer.Tick += (_, _) => Dismiss(); + } + + public KnownLayer Layer => KnownLayer.Selection; + + public void Draw(AvaloniaEdit.Rendering.TextView textView, DrawingContext drawingContext) + { + long ms = elapsed.ElapsedMilliseconds; + if (ms >= LifetimeMs) + return; + + // The line may not be laid out yet on the first frame after a fresh decompile; once it is, + // VisualLines carries it and the highlight appears. + var visualLine = textView.GetVisualLine(line); + if (visualLine == null) + return; + + double top = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineTop) - textView.VerticalOffset; + double height = visualLine.Height; + + // Hold at full strength, then linear fade to zero over the rest of the lifetime. + double opacity = ms < HoldMs ? 1.0 : 1.0 - (ms - HoldMs) / (double)(LifetimeMs - HoldMs); + if (opacity <= 0) + return; + + using var _ = drawingContext.PushOpacity(opacity); + drawingContext.DrawRectangle(Fill, null, new Rect(0, top, textView.Bounds.Width, height)); + } + + /// Registers a one-shot line highlight for on . + public static void DisplayLineHighlight(TextArea textArea, int line) + { + ArgumentNullException.ThrowIfNull(textArea); + + // Clear any still-running highlight first, so quick successive navigations replace rather + // than stack adorners (each carries its own pair of timers driving redraws). + foreach (var existing in textArea.TextView.BackgroundRenderers.OfType().ToArray()) + existing.Dismiss(); + + var adorner = new LineHighlightAdorner(textArea, line); + textArea.TextView.BackgroundRenderers.Add(adorner); + adorner.frameTimer.Start(); + adorner.lifetimeTimer.Start(); + } + + /// Ends the highlight immediately: stops the timers and unregisters from the text view. + public void Dismiss() + { + lifetimeTimer.Stop(); + frameTimer.Stop(); + textArea.TextView.BackgroundRenderers.Remove(this); + } + } +} diff --git a/ILSpy/ViewLocator.cs b/ILSpy/ViewLocator.cs index 91c0cf2ea6..7663831403 100644 --- a/ILSpy/ViewLocator.cs +++ b/ILSpy/ViewLocator.cs @@ -27,6 +27,7 @@ using ICSharpCode.ILSpy.Analyzers; using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.Bookmarks; using ICSharpCode.ILSpy.Compare; using ICSharpCode.ILSpy.Options; using ICSharpCode.ILSpy.Search; @@ -62,6 +63,7 @@ public class ViewLocator : IDataTemplate static readonly Dictionary> s_views = new() { { typeof(AssemblyTreeModel), () => new AssemblyListPane() }, { typeof(SearchPaneModel), () => new SearchPane() }, + { typeof(BookmarksPaneModel), () => new BookmarksPane() }, { typeof(AnalyzerTreeViewModel), () => new AnalyzerTreeView() }, { typeof(ContentTabPage), () => new ContentTabPageView() }, { typeof(DecompilerTabPageModel), () => new DecompilerTextView() },