From cf3f5fc7e106c037ac0feea46a2fecb960a0fa47 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 17 Jun 2026 15:56:26 +0200 Subject: [PATCH 01/13] Add a bookmarks feature for the decompiled C# view There was no way to mark and return to interesting spots in decompiled code. This adds a flat (no folders, no labels) bookmark list: toggle on a line from the context menu, the gutter, or Ctrl+B; see an icon in a new left-margin gutter that honours a disabled state; and manage the list in a dockable pane that auto-registers in the Window menu. Bookmarks anchor by metadata token, never by a raw line number, so they survive re-decompilation and decompiler-setting changes that reflow the C# text: a definition line anchors to its token, while a line inside a method body anchors to the method token plus an IL offset. Recovering an IL offset needs the decompiler's sequence points, which the normal C# output did not carry, so they are captured once at the WriteCode chokepoint and stored as a per-document line/offset map. The map is also what places gutter icons and scrolls navigation to the exact line. The list persists to an ILSpy.Bookmarks.json sidecar next to ILSpy.xml; the path logic is extracted into AppEnv/ConfigurationFiles so the dock layout sidecar shares it. Navigating to a bookmark loads its assembly from disk if it dropped out of the list (and only then offers to remove a bookmark whose file is gone), then centres the line and plays a brief line flash plus a gutter-icon pulse. Disabled bookmarks stay visible but are skipped by the next/previous actions. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs | 62 ++++ .../Bookmarks/BookmarkAnchoringTests.cs | 76 +++++ .../BookmarkDebugInfoCaptureTests.cs | 71 +++++ ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs | 185 ++++++++++++ .../Bookmarks/BookmarkNavigationStepTests.cs | 75 +++++ ILSpy.Tests/Bookmarks/MethodDebugInfoTests.cs | 87 ++++++ ILSpy.Tests/Docking/DockWorkspaceTests.cs | 10 +- .../Editor/LineHighlightAdornerTests.cs | 51 ++++ ILSpy/AppEnv/ConfigurationFiles.cs | 47 +++ ILSpy/Assets/Icons/Bookmark.Clear.svg | 1 + ILSpy/Assets/Icons/Bookmark.GroupDisable.svg | 1 + ILSpy/Assets/Icons/Bookmark.Next.File.svg | 1 + ILSpy/Assets/Icons/Bookmark.Next.Folder.svg | 1 + ILSpy/Assets/Icons/Bookmark.Next.svg | 1 + ILSpy/Assets/Icons/Bookmark.Previous.File.svg | 1 + .../Assets/Icons/Bookmark.Previous.Folder.svg | 42 +++ ILSpy/Assets/Icons/Bookmark.Previous.svg | 1 + ILSpy/Assets/Icons/Bookmark.Window.svg | 1 + ILSpy/Assets/Icons/Bookmark.svg | 1 + ILSpy/Assets/Icons/Boomark.Disable.svg | 1 + ILSpy/Bookmarks/Bookmark.cs | 80 ++++++ ILSpy/Bookmarks/BookmarkAnchoring.cs | 85 ++++++ ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs | 103 +++++++ ILSpy/Bookmarks/BookmarkDialogs.cs | 94 ++++++ ILSpy/Bookmarks/BookmarkManager.cs | 267 ++++++++++++++++++ ILSpy/Bookmarks/BookmarkMargin.cs | 147 ++++++++++ ILSpy/Bookmarks/BookmarkNavigator.cs | 112 ++++++++ ILSpy/Bookmarks/BookmarksPane.axaml | 105 +++++++ ILSpy/Bookmarks/BookmarksPane.axaml.cs | 43 +++ ILSpy/Bookmarks/BookmarksPaneModel.cs | 162 +++++++++++ ILSpy/Bookmarks/MethodDebugInfo.cs | 148 ++++++++++ .../ToggleBookmarkContextMenuEntry.cs | 40 +++ ILSpy/Commands/FilePickers.cs | 19 ++ ILSpy/Docking/DockWorkspace.cs | 18 +- ILSpy/Images.cs | 10 + ILSpy/Languages/CSharpLanguage.cs | 33 ++- ILSpy/Properties/Resources.Designer.cs | 157 +++++++++- ILSpy/Properties/Resources.resx | 51 ++++ ILSpy/TextView/AvaloniaEditTextOutput.cs | 16 ++ ILSpy/TextView/DecompilerTabPageModel.cs | 24 ++ ILSpy/TextView/DecompilerTextView.axaml.cs | 173 ++++++++++++ ILSpy/TextView/LineHighlightAdorner.cs | 106 +++++++ ILSpy/ViewLocator.cs | 2 + 43 files changed, 2678 insertions(+), 33 deletions(-) create mode 100644 ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs create mode 100644 ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs create mode 100644 ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs create mode 100644 ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs create mode 100644 ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs create mode 100644 ILSpy.Tests/Bookmarks/MethodDebugInfoTests.cs create mode 100644 ILSpy.Tests/Editor/LineHighlightAdornerTests.cs create mode 100644 ILSpy/AppEnv/ConfigurationFiles.cs create mode 100644 ILSpy/Assets/Icons/Bookmark.Clear.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.GroupDisable.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Next.File.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Next.Folder.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Next.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Previous.File.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Previous.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.Window.svg create mode 100644 ILSpy/Assets/Icons/Bookmark.svg create mode 100644 ILSpy/Assets/Icons/Boomark.Disable.svg create mode 100644 ILSpy/Bookmarks/Bookmark.cs create mode 100644 ILSpy/Bookmarks/BookmarkAnchoring.cs create mode 100644 ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs create mode 100644 ILSpy/Bookmarks/BookmarkDialogs.cs create mode 100644 ILSpy/Bookmarks/BookmarkManager.cs create mode 100644 ILSpy/Bookmarks/BookmarkMargin.cs create mode 100644 ILSpy/Bookmarks/BookmarkNavigator.cs create mode 100644 ILSpy/Bookmarks/BookmarksPane.axaml create mode 100644 ILSpy/Bookmarks/BookmarksPane.axaml.cs create mode 100644 ILSpy/Bookmarks/BookmarksPaneModel.cs create mode 100644 ILSpy/Bookmarks/MethodDebugInfo.cs create mode 100644 ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs create mode 100644 ILSpy/TextView/LineHighlightAdorner.cs 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..a2581e4047 --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs @@ -0,0 +1,76 @@ +// 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. + BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, 1).Should().BeNull(); + } +} 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/BookmarkManagerTests.cs b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs new file mode 100644 index 0000000000..b96e2f0f7e --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs @@ -0,0 +1,185 @@ +// 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 = "") + => new() { + Name = name, + FileName = @"C:\asm\Sample.dll", + AssemblyFullName = "Sample, Version=1.0.0.0", + ModuleName = "Sample.dll", + Token = token, + Kind = kind, + ILOffset = ilOffset, + MemberName = "Sample.Type.Member", + }; + + [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 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 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"); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs new file mode 100644 index 0000000000..0bd4950771 --- /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, the middle one disabled (the reported scenario). + 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/MethodDebugInfoTests.cs b/ILSpy.Tests/Bookmarks/MethodDebugInfoTests.cs new file mode 100644 index 0000000000..7a43dce16a --- /dev/null +++ b/ILSpy.Tests/Bookmarks/MethodDebugInfoTests.cs @@ -0,0 +1,87 @@ +// 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; + +// The IL-offset <-> line map is the heart of the in-method (body) anchor. These tests pin the +// line->offset (save) and offset->line (display/navigate) round trip, including the "nearest +// statement at or before the offset" fallback that lets a bookmark survive a reflow. +[TestFixture] +public class MethodDebugInfoTests +{ + const uint Token = 0x06000001; + + static MethodDebugInfo Method() => new( + Token, @"C:\asm\Sample.dll", "Sample", "Sample.dll", "Sample.Type.M", + new List<(int Line, int ILOffset)> { (5, 0), (6, 8), (7, 16) }); + + [Test] + public void Line_maps_to_the_statement_offset() + { + Method().TryGetOffsetForLine(6, out var offset).Should().BeTrue(); + offset.Should().Be(8); + } + + [Test] + public void A_line_without_a_statement_has_no_offset() + { + Method().TryGetOffsetForLine(99, out _).Should().BeFalse(); + } + + [Test] + public void Exact_offset_maps_back_to_its_line() + { + Method().TryGetLineForOffset(8, out var line).Should().BeTrue(); + line.Should().Be(6); + } + + [Test] + public void Offset_between_statements_snaps_to_the_one_before() + { + Method().TryGetLineForOffset(12, out var line).Should().BeTrue(); + line.Should().Be(6); + } + + [Test] + public void Document_resolves_a_body_anchor_and_back() + { + var info = new DecompiledDebugInfo(new List { Method() }); + + info.TryGetBodyAnchor(7, out var method, out var offset).Should().BeTrue(); + method.Token.Should().Be(Token); + offset.Should().Be(16); + + info.TryGetLine(Token, offset, out var line).Should().BeTrue(); + line.Should().Be(7); + } + + [Test] + public void Document_has_no_body_anchor_off_any_statement() + { + var info = new DecompiledDebugInfo(new List { Method() }); + info.TryGetBodyAnchor(1, out _, out _).Should().BeFalse(); + } +} diff --git a/ILSpy.Tests/Docking/DockWorkspaceTests.cs b/ILSpy.Tests/Docking/DockWorkspaceTests.cs index a8ce819062..c97c0e14d3 100644 --- a/ILSpy.Tests/Docking/DockWorkspaceTests.cs +++ b/ILSpy.Tests/Docking/DockWorkspaceTests.cs @@ -35,7 +35,7 @@ namespace ICSharpCode.ILSpy.Tests.Docking; public class DockWorkspaceTests { [AvaloniaTest] - public void DockWorkspace_resolves_and_exposes_root_layout_with_three_tool_panes() + public void DockWorkspace_resolves_and_exposes_root_layout_with_tool_panes() { var workspace = AppComposition.Current.GetExport(); workspace.Should().NotBeNull("DockWorkspace is [Export][Shared] in ICSharpCode.ILSpy.Docking."); @@ -43,11 +43,11 @@ public void DockWorkspace_resolves_and_exposes_root_layout_with_three_tool_panes workspace.Layout.Should().NotBeNull("ILSpyDockFactory.CreateLayout() wires the root dock in the ctor."); workspace.Factory.Should().NotBeNull(); #if DEBUG - workspace.ToolPaneMenuItems.Should().HaveCount(4, - "AssemblyTree, Search, Analyzers, and the Debug Steps pane (Debug-only) are wired at this point."); + workspace.ToolPaneMenuItems.Should().HaveCount(5, + "AssemblyTree, Search, Analyzers, Bookmarks, and the Debug Steps pane (Debug-only) are wired at this point."); #else - workspace.ToolPaneMenuItems.Should().HaveCount(3, - "AssemblyTree, Search, and Analyzers are the three tool panes wired at this point."); + workspace.ToolPaneMenuItems.Should().HaveCount(4, + "AssemblyTree, Search, Analyzers, and Bookmarks are the tool panes wired at this point."); #endif } } diff --git a/ILSpy.Tests/Editor/LineHighlightAdornerTests.cs b/ILSpy.Tests/Editor/LineHighlightAdornerTests.cs new file mode 100644 index 0000000000..133775ab50 --- /dev/null +++ b/ILSpy.Tests/Editor/LineHighlightAdornerTests.cs @@ -0,0 +1,51 @@ +// 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 AvaloniaEdit; + +using Avalonia.Headless.NUnit; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.TextView; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +// The line-highlight flash that plays alongside the gutter-icon pulse on a bookmark navigation. +// The animation curve / timing are eyeballed; this pins the register-then-unregister lifecycle. +[TestFixture] +public class LineHighlightAdornerTests +{ + [AvaloniaTest] + public void DisplayLineHighlight_registers_and_Dismiss_unregisters() + { + var editor = new TextEditor { Document = new AvaloniaEdit.Document.TextDocument("line1\nline2\nline3") }; + var renderers = editor.TextArea.TextView.BackgroundRenderers; + renderers.Should().NotContain(r => r is LineHighlightAdorner); + + LineHighlightAdorner.DisplayLineHighlight(editor.TextArea, 2); + renderers.Should().Contain(r => r is LineHighlightAdorner, "DisplayLineHighlight adds the adorner"); + + renderers.OfType().Single().Dismiss(); + renderers.Should().NotContain(r => r is LineHighlightAdorner, "Dismiss unregisters the adorner"); + } +} diff --git a/ILSpy/AppEnv/ConfigurationFiles.cs b/ILSpy/AppEnv/ConfigurationFiles.cs new file mode 100644 index 0000000000..120ceae637 --- /dev/null +++ b/ILSpy/AppEnv/ConfigurationFiles.cs @@ -0,0 +1,47 @@ +// 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.IO; + +using ICSharpCode.ILSpyX.Settings; + +namespace ICSharpCode.ILSpy.AppEnv +{ + /// + /// Resolves auxiliary configuration files that live as JSON sidecars next to the main + /// ILSpy.xml settings file (the dock layout, the bookmark list, ...). Keeping them + /// in the same directory the XML settings live in — local-to-binary on portable installs, + /// %APPDATA%/ICSharpCode/ otherwise — makes "delete settings to reset" a single-folder action. + /// + public static class ConfigurationFiles + { + /// + /// Returns the full path for in the settings directory. + /// Falls back to a bare relative name when the settings path can't be resolved (e.g. in + /// headless tests that never set ). + /// + public static string GetPath(string fileName) + { + var xmlPath = ILSpySettings.SettingsFilePathProvider?.Invoke(); + if (string.IsNullOrEmpty(xmlPath)) + return fileName; + var dir = Path.GetDirectoryName(xmlPath); + return string.IsNullOrEmpty(dir) ? fileName : Path.Combine(dir, fileName); + } + } +} diff --git a/ILSpy/Assets/Icons/Bookmark.Clear.svg b/ILSpy/Assets/Icons/Bookmark.Clear.svg new file mode 100644 index 0000000000..80ba6d07f5 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Clear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.GroupDisable.svg b/ILSpy/Assets/Icons/Bookmark.GroupDisable.svg new file mode 100644 index 0000000000..4a06c73f69 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.GroupDisable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.Next.File.svg b/ILSpy/Assets/Icons/Bookmark.Next.File.svg new file mode 100644 index 0000000000..5d6aca6a5b --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Next.File.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.Next.Folder.svg b/ILSpy/Assets/Icons/Bookmark.Next.Folder.svg new file mode 100644 index 0000000000..2bb717059e --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Next.Folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.Next.svg b/ILSpy/Assets/Icons/Bookmark.Next.svg new file mode 100644 index 0000000000..dbdf8f1b38 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.Previous.File.svg b/ILSpy/Assets/Icons/Bookmark.Previous.File.svg new file mode 100644 index 0000000000..eb07fb1d89 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Previous.File.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg b/ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg new file mode 100644 index 0000000000..43594b8ec0 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ILSpy/Assets/Icons/Bookmark.Previous.svg b/ILSpy/Assets/Icons/Bookmark.Previous.svg new file mode 100644 index 0000000000..f0f82f00ea --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Previous.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.Window.svg b/ILSpy/Assets/Icons/Bookmark.Window.svg new file mode 100644 index 0000000000..2c507fd3a1 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.Window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Bookmark.svg b/ILSpy/Assets/Icons/Bookmark.svg new file mode 100644 index 0000000000..35f8be8ba7 --- /dev/null +++ b/ILSpy/Assets/Icons/Bookmark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Assets/Icons/Boomark.Disable.svg b/ILSpy/Assets/Icons/Boomark.Disable.svg new file mode 100644 index 0000000000..7525481728 --- /dev/null +++ b/ILSpy/Assets/Icons/Boomark.Disable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ILSpy/Bookmarks/Bookmark.cs b/ILSpy/Bookmarks/Bookmark.cs new file mode 100644 index 0000000000..d2c4c59561 --- /dev/null +++ b/ILSpy/Bookmarks/Bookmark.cs @@ -0,0 +1,80 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// How a is anchored to a decompiled member. The anchor is a metadata + /// token (never a raw line number) so it survives re-decompilation and decompiler-setting + /// changes that reflow the C# text. + /// + public enum BookmarkKind + { + /// A definition line (type, method, field, property, event): module + token. + Token, + + /// A line inside a method body: module + method token + IL offset. + Body + } + + /// + /// A single user bookmark in the flat bookmark list. The anchor fields are immutable + /// (set when the bookmark is created); only and + /// change at runtime, both bound two-way in the bookmarks pane. + /// + public sealed partial class Bookmark : ObservableObject + { + [ObservableProperty] + private string name = ""; + + [ObservableProperty] + private bool enabled = true; + + /// Path of the assembly file, used to reload it from disk on navigation. + public required string FileName { get; init; } + + /// Assembly identity (also part of the duplicate/merge key). + public required string AssemblyFullName { get; init; } + + /// Module file name, shown in the pane's "Module" column. + public required string ModuleName { get; init; } + + /// Metadata token of the member (or, for , the enclosing method). + public required uint Token { get; init; } + + public required BookmarkKind Kind { get; init; } + + /// IL offset within the method body; meaningful only for . + public int ILOffset { get; init; } + + /// + /// Display name of the anchored member, shown in the pane's "Location" column and used as a + /// stale-token guard: if a reloaded module's token no longer resolves to this member, the + /// bookmark is treated as missing rather than silently pointing at the wrong code. + /// + public required string MemberName { get; init; } + + /// + /// Identity used for toggle and merge-import deduplication: same assembly + same location + /// (token, plus IL offset for body anchors) means the same bookmark. + /// + public string AnchorKey => $"{AssemblyFullName}|{ModuleName}|{Token}|{(Kind == BookmarkKind.Body ? ILOffset : -1)}"; + } +} diff --git a/ILSpy/Bookmarks/BookmarkAnchoring.cs b/ILSpy/Bookmarks/BookmarkAnchoring.cs new file mode 100644 index 0000000000..2bf4256786 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkAnchoring.cs @@ -0,0 +1,85 @@ +// 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.IO; +using System.Reflection.Metadata.Ecma335; + +using AvaloniaEdit.Document; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.TextView; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// Turns a clicked line in the decompiled C# view into a anchor: a line + /// that starts a statement becomes an IL-offset body anchor; a definition line becomes a token + /// anchor; any other line is not bookmarkable. + /// + public static class BookmarkAnchoring + { + /// + /// Builds an unnamed candidate bookmark for , or null when the line + /// is neither a statement nor a definition (e.g. a blank line or a lone brace). + /// + public static Bookmark? CreateForLine(DecompiledDebugInfo? debugInfo, + TextSegmentCollection? references, TextDocument document, int line) + { + if (debugInfo != null && debugInfo.TryGetBodyAnchor(line, out var method, out var ilOffset)) + { + return new Bookmark { + Kind = BookmarkKind.Body, + Token = method.Token, + ILOffset = ilOffset, + FileName = method.FileName, + AssemblyFullName = method.AssemblyFullName, + ModuleName = method.ModuleName, + MemberName = method.MemberName, + }; + } + + if (references != null && document != null && line >= 1 && line <= document.LineCount) + { + var docLine = document.GetLineByNumber(line); + foreach (var segment in references.FindOverlappingSegments(docLine.Offset, docLine.Length)) + { + if (segment.IsDefinition && segment.Reference is IEntity entity && CreateForEntity(entity) is { } bookmark) + return bookmark; + } + } + + return null; + } + + static Bookmark? CreateForEntity(IEntity entity) + { + var file = entity.ParentModule?.MetadataFile; + if (file == null) + return null; + string moduleName = string.IsNullOrEmpty(file.FileName) ? file.Name : Path.GetFileName(file.FileName); + return new Bookmark { + Kind = BookmarkKind.Token, + Token = (uint)MetadataTokens.GetToken(entity.MetadataToken), + FileName = file.FileName, + AssemblyFullName = file.FullName, + ModuleName = moduleName, + MemberName = entity.FullName, + }; + } + } +} diff --git a/ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs b/ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs new file mode 100644 index 0000000000..b6bb59f32e --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs @@ -0,0 +1,103 @@ +// 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.IO; +using System.Linq; +using System.Reflection.Metadata.Ecma335; + +using ICSharpCode.Decompiler.CSharp.OutputVisitor; +using ICSharpCode.Decompiler.CSharp.Syntax; +using ICSharpCode.Decompiler.IL; +using ICSharpCode.ILSpy.TextView; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// Harvests the IL-offset/text-line map for body bookmarks during the single C# formatting pass. + /// As the output visitor writes each AST node, the node still carries the + /// (s) it was built from; pairing an instruction's IL offset with the + /// document line the node lands on yields the same line/offset map a body anchor needs. + /// + /// Because the line numbers are read from the very output being displayed, the map needs no + /// second formatting pass and no separate sequence-point generation, and there is no cross-pass + /// "the two writers must break lines identically" assumption to hold. + /// + sealed class BookmarkDebugInfoCollector : DecoratingTokenWriter + { + readonly AvaloniaEditTextOutput output; + // Per top-level function: the lowest IL offset of any instruction whose node starts on a given + // document line. Lowest-on-the-line matches MethodDebugInfo.TryGetOffsetForLine's contract. + readonly Dictionary> offsetByLinePerFunction = new(); + + public BookmarkDebugInfoCollector(TokenWriter decoratedWriter, AvaloniaEditTextOutput output) + : base(decoratedWriter) + { + this.output = output; + } + + public override void StartNode(AstNode node) + { + base.StartNode(node); + // CurrentLine is the line this node's first token will land on: the preceding newline has + // already advanced it, and indentation/tokens for this node are only written afterwards. + int line = output.CurrentLine; + foreach (var inst in node.Annotations.OfType()) + { + if (!HasUsableILRange(inst)) + continue; + // The instruction's nearest enclosing function owns the IL offset. Only a top-level + // function has a metadata token that resolves to a navigable member; an offset inside a + // lambda/local function is skipped, so a click there falls back to a member (token) anchor. + var function = inst.Parent!.Ancestors.OfType().FirstOrDefault(); + if (function is not { Kind: ILFunctionKind.TopLevelFunction, Method: not null }) + continue; + if (!offsetByLinePerFunction.TryGetValue(function, out var offsetByLine)) + offsetByLinePerFunction[function] = offsetByLine = new Dictionary(); + int offset = inst.StartILOffset; + if (!offsetByLine.TryGetValue(line, out int existing) || offset < existing) + offsetByLine[line] = offset; + } + } + + // Mirrors SequencePointBuilder.HasUsableILRange: an instruction contributes a position only when + // it has a non-empty IL range, is connected to the tree, and is not a whole-body (block) node. + static bool HasUsableILRange(ILInstruction inst) + => !inst.ILRangeIsEmpty && inst.Parent != null && inst is not (BlockContainer or Block); + + /// + /// Registers a for each captured function with the output. Call + /// once, after the formatting pass has visited the whole syntax tree. + /// + public void Publish() + { + foreach (var (function, offsetByLine) in offsetByLinePerFunction) + { + var method = function.Method!; + var file = method.ParentModule?.MetadataFile; + if (file == null) + continue; + var lines = offsetByLine.Select(entry => (Line: entry.Key, ILOffset: entry.Value)).ToList(); + uint token = (uint)MetadataTokens.GetToken(method.MetadataToken); + string moduleName = string.IsNullOrEmpty(file.FileName) ? file.Name : Path.GetFileName(file.FileName); + output.AddMethodDebugInfo(new MethodDebugInfo( + token, file.FileName, file.FullName, moduleName, method.FullName, lines)); + } + } + } +} diff --git a/ILSpy/Bookmarks/BookmarkDialogs.cs b/ILSpy/Bookmarks/BookmarkDialogs.cs new file mode 100644 index 0000000000..af51fd68aa --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkDialogs.cs @@ -0,0 +1,94 @@ +// 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.Threading.Tasks; + +using Avalonia.Controls; +using Avalonia.Layout; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Properties; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// The two small confirmations the bookmark feature needs: "remove a bookmark whose assembly is + /// gone?" and "replace or merge on import?". Built in code (no XAML) since they are plain + /// message + button prompts, and the app has no general-purpose message box. + /// + static class BookmarkDialogs + { + public static async Task ConfirmRemoveMissingAsync() + => "yes" == await ShowChoiceAsync(Resources.Bookmarks, Resources.BookmarkAssemblyMissing, + (Resources._Remove, "yes"), (Resources.Cancel, "no")); + + public static async Task AskImportModeAsync() + { + var result = await ShowChoiceAsync(Resources.BookmarkImportTitle, Resources.BookmarkImportReplaceOrMerge, + (Resources.BookmarkImportReplace, "replace"), (Resources.BookmarkImportMerge, "merge"), (Resources.Cancel, "cancel")); + return result switch { + "replace" => BookmarkImportMode.Replace, + "merge" => BookmarkImportMode.Merge, + _ => null, + }; + } + + // Shows a modal prompt with one button per choice; returns the chosen result, or null if the + // window was closed without a choice. + static async Task ShowChoiceAsync(string title, string message, params (string Label, string Result)[] choices) + { + var owner = UiContext.MainWindow; + if (owner == null) + return null; + + string? result = null; + var buttons = new StackPanel { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right, + Spacing = 8, + Margin = new global::Avalonia.Thickness(0, 16, 0, 0), + }; + var window = new Window { + Title = title, + SizeToContent = SizeToContent.WidthAndHeight, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + ShowInTaskbar = false, + }; + foreach (var (label, value) in choices) + { + var button = new Button { Content = label, MinWidth = 80 }; + button.Click += (_, _) => { + result = value; + window.Close(); + }; + buttons.Children.Add(button); + } + window.Content = new StackPanel { + Margin = new global::Avalonia.Thickness(16), + MaxWidth = 480, + Children = { + new TextBlock { Text = message, TextWrapping = global::Avalonia.Media.TextWrapping.Wrap }, + buttons, + }, + }; + await window.ShowDialog(owner); + return result; + } + } +} diff --git a/ILSpy/Bookmarks/BookmarkManager.cs b/ILSpy/Bookmarks/BookmarkManager.cs new file mode 100644 index 0000000000..3a640ad5a5 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkManager.cs @@ -0,0 +1,267 @@ +// 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.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Composition; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +using ICSharpCode.ILSpy.AppEnv; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// What to do when importing into a non-empty list. + public enum BookmarkImportMode + { + /// Discard the current list and replace it with the imported one. + Replace, + + /// Keep the current list, adding only imported bookmarks not already present. + Merge + } + + /// + /// The single source of truth for the flat bookmark list. Holds the live + /// , persists it to ILSpy.Bookmarks.json + /// next to ILSpy.xml, and raises whenever the list or any + /// bookmark's name/enabled state changes so the gutter margin can redraw. Mirrors the + /// best-effort load/save of the dock layout sidecar. + /// + [Export] + [Shared] + public sealed class BookmarkManager + { + const string FileName = "ILSpy.Bookmarks.json"; + const int CurrentVersion = 1; + + static readonly JsonSerializerOptions JsonOptions = new() { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + + bool suppressPersist; + + public BookmarkManager() + { + Bookmarks.CollectionChanged += OnCollectionChanged; + Load(); + } + + /// The live list, bound directly by the bookmarks pane. + public ObservableCollection Bookmarks { get; } = new(); + + /// Raised after any structural change or any bookmark name/enabled edit. + public event EventHandler? Changed; + + /// + /// Adds when no bookmark with the same anchor exists, or removes + /// the existing one when it does. Returns true if a bookmark was added, false if removed. + /// Assigns a default name when the incoming bookmark has none. + /// + public bool Toggle(Bookmark bookmark) + { + ArgumentNullException.ThrowIfNull(bookmark); + var existing = Bookmarks.FirstOrDefault(b => b.AnchorKey == bookmark.AnchorKey); + if (existing != null) + { + Bookmarks.Remove(existing); + return false; + } + if (string.IsNullOrEmpty(bookmark.Name)) + bookmark.Name = NextDefaultName(); + Bookmarks.Add(bookmark); + return true; + } + + public void Remove(Bookmark bookmark) => Bookmarks.Remove(bookmark); + + public void Clear() => Bookmarks.Clear(); + + /// Writes the current list to in the on-disk JSON format. + public void Export(string path) + { + ArgumentNullException.ThrowIfNull(path); + WriteTo(path); + } + + /// + /// Loads bookmarks from . In + /// only entries whose anchor is not already present are added; in + /// the current list is discarded first. + /// + public void Import(string path, BookmarkImportMode mode) + { + ArgumentNullException.ThrowIfNull(path); + var imported = ReadFrom(path); + if (imported.Count == 0 && mode == BookmarkImportMode.Merge) + return; + + RunBatch(() => { + if (mode == BookmarkImportMode.Replace) + Bookmarks.Clear(); + var present = new HashSet(Bookmarks.Select(b => b.AnchorKey)); + foreach (var b in imported) + { + if (present.Add(b.AnchorKey)) + Bookmarks.Add(b); + } + }); + } + + /// Persists the current list to the standard sidecar location. + public void Save() => WriteTo(ConfigurationFiles.GetPath(FileName)); + + void Load() + { + var loaded = ReadFrom(ConfigurationFiles.GetPath(FileName)); + // Suppress persistence during the initial load: reading the list back is not a change, + // and we don't want to rewrite (or create) the file just by starting the app. + suppressPersist = true; + try + { + foreach (var b in loaded) + Bookmarks.Add(b); + } + finally + { + suppressPersist = false; + } + } + + // Applies a multi-step mutation without persisting/raising per step, then persists once. + void RunBatch(Action action) + { + suppressPersist = true; + try + { + action(); + } + finally + { + suppressPersist = false; + } + PersistAndNotify(); + } + + string NextDefaultName() + { + // Pick the lowest unused "Bookmark{n}" so names stay stable and unsurprising. + var used = new HashSet(Bookmarks.Select(b => b.Name)); + for (int i = 0; ; i++) + { + var candidate = "Bookmark" + i; + if (used.Add(candidate)) + return candidate; + } + } + + void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.OldItems != null) + { + foreach (Bookmark b in e.OldItems) + b.PropertyChanged -= OnBookmarkPropertyChanged; + } + if (e.NewItems != null) + { + foreach (Bookmark b in e.NewItems) + b.PropertyChanged += OnBookmarkPropertyChanged; + } + PersistAndNotify(); + } + + void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e) => PersistAndNotify(); + + void PersistAndNotify() + { + if (suppressPersist) + return; + Save(); + Changed?.Invoke(this, EventArgs.Empty); + } + + void WriteTo(string path) + { + try + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + var file = new BookmarkFile(CurrentVersion, Bookmarks.Select(BookmarkRecord.From).ToList()); + using var stream = System.IO.File.Create(path); + JsonSerializer.Serialize(stream, file, JsonOptions); + } + catch (Exception ex) + { + Debug.WriteLine($"[BookmarkManager] Save failed: {ex}"); + } + } + + static List ReadFrom(string path) + { + if (!System.IO.File.Exists(path)) + return new List(); + try + { + using var stream = System.IO.File.OpenRead(path); + var file = JsonSerializer.Deserialize(stream, JsonOptions); + return file?.Bookmarks? + // A bookmark must reference an assembly file. Entries without one are artifacts + // (e.g. a stray empty row) that could never navigate; drop them defensively. + .Where(r => !string.IsNullOrEmpty(r.FileName)) + .Select(r => r.ToBookmark()).ToList() ?? new List(); + } + catch (Exception ex) + { + Debug.WriteLine($"[BookmarkManager] Load failed: {ex}"); + return new List(); + } + } + + // On-disk shapes. Kept separate from the runtime Bookmark so the file format is explicit + // and decoupled from the observable object. + sealed record BookmarkFile(int Version, List Bookmarks); + + sealed record BookmarkRecord( + string Name, bool Enabled, string FileName, string AssemblyFullName, + string ModuleName, uint Token, BookmarkKind Kind, int ILOffset, string MemberName) + { + public static BookmarkRecord From(Bookmark b) => new( + b.Name, b.Enabled, b.FileName, b.AssemblyFullName, b.ModuleName, b.Token, b.Kind, b.ILOffset, b.MemberName); + + public Bookmark ToBookmark() => new() { + Name = Name, + Enabled = Enabled, + FileName = FileName, + AssemblyFullName = AssemblyFullName, + ModuleName = ModuleName, + Token = Token, + Kind = Kind, + ILOffset = ILOffset, + MemberName = MemberName, + }; + } + } +} diff --git a/ILSpy/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs new file mode 100644 index 0000000000..6946f7fb09 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -0,0 +1,147 @@ +// 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.Collections.Generic; +using System.Diagnostics; + +using Avalonia; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; + +using AvaloniaEdit.Editing; +using AvaloniaEdit.Rendering; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// The icon gutter to the left of the line numbers in the decompiled C# view. Draws a bookmark + /// glyph (greyed when disabled) on every line that holds a bookmark, and toggles a bookmark when + /// the gutter is clicked -- the line <-> bookmark mapping is owned by the text view. After a + /// navigation the destination glyph briefly pulses so the user spots which line they landed on. + /// + public sealed class BookmarkMargin : AbstractMargin + { + const double IconSize = 16; + // One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs. + const double PulseAmount = 0.35; + const int PulseDurationMs = 600; + + readonly TextView.DecompilerTextView owner; + readonly BookmarkManager? manager; + readonly DispatcherTimer pulseTimer; + readonly Stopwatch pulseElapsed = new(); + int pulseLine = -1; + + public BookmarkMargin(TextView.DecompilerTextView owner) + { + this.owner = owner; + manager = AppEnv.AppComposition.TryGetExport(); + if (manager != null) + manager.Changed += OnBookmarksChanged; + pulseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; + pulseTimer.Tick += OnPulseTick; + } + + void OnBookmarksChanged(object? sender, EventArgs e) => InvalidateVisual(); + + /// Plays the one-shot bounce on the bookmark glyph at . + public void PulseLine(int line) + { + pulseLine = line; + pulseElapsed.Restart(); + pulseTimer.Start(); + InvalidateVisual(); + } + + void OnPulseTick(object? sender, EventArgs e) + { + if (pulseElapsed.ElapsedMilliseconds >= PulseDurationMs) + { + pulseTimer.Stop(); + pulseLine = -1; + } + InvalidateVisual(); + } + + // Scale for the pulsing glyph: 1 -> 1+PulseAmount -> 1 over the pulse lifetime. + double CurrentPulseScale() + { + double t = pulseElapsed.ElapsedMilliseconds / (double)PulseDurationMs; + return t >= 1 ? 1.0 : 1.0 + PulseAmount * Math.Sin(Math.PI * t); + } + + protected override Size MeasureOverride(Size availableSize) => new(IconSize, 0); + + public override void Render(DrawingContext drawingContext) + { + var textView = TextView; + if (manager == null || textView == null || !textView.VisualLinesValid) + return; + + // Map the document lines that currently hold a bookmark to their glyph. Bookmarks not in + // this document resolve to no line and are skipped. + var glyphByLine = new Dictionary(); + foreach (var bookmark in manager.Bookmarks) + { + if (owner.GetLineForBookmark(bookmark) is { } line) + glyphByLine[line] = bookmark.Enabled ? Images.Bookmark : Images.BookmarkDisable; + } + if (glyphByLine.Count == 0) + return; + + foreach (var visualLine in textView.VisualLines) + { + int lineNumber = visualLine.FirstDocumentLine.LineNumber; + if (!glyphByLine.TryGetValue(lineNumber, out var glyph)) + continue; + double top = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.TextTop) - textView.VerticalOffset; + var rect = new Rect(0, top, IconSize, IconSize); + + double scale = lineNumber == pulseLine ? CurrentPulseScale() : 1.0; + if (scale != 1.0) + { + // Scale about the glyph centre so it grows in place. + double cx = IconSize / 2, cy = top + IconSize / 2; + var transform = Matrix.CreateTranslation(-cx, -cy) * Matrix.CreateScale(scale, scale) * Matrix.CreateTranslation(cx, cy); + using (drawingContext.PushTransform(transform)) + drawingContext.DrawImage(glyph, rect); + } + else + { + drawingContext.DrawImage(glyph, rect); + } + } + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + var textView = TextView; + if (e.Handled || textView == null) + return; + double y = e.GetPosition(textView).Y + textView.VerticalOffset; + var visualLine = textView.GetVisualLineFromVisualTop(y); + if (visualLine == null) + return; + owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber); + e.Handled = true; + } + } +} diff --git a/ILSpy/Bookmarks/BookmarkNavigator.cs b/ILSpy/Bookmarks/BookmarkNavigator.cs new file mode 100644 index 0000000000..02e9fe8b4c --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkNavigator.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.Composition; +using System.IO; +using System.Reflection.Metadata.Ecma335; +using System.Threading.Tasks; + +using ICSharpCode.Decompiler.TypeSystem; +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.Docking; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// + /// Navigates to a bookmark's location: it makes sure the assembly is loaded (loading it from + /// disk if it dropped out of the list), resolves the token to an entity, selects the matching + /// tree node, and asks the text view to scroll to the exact line once the document is shown. A + /// bookmark whose assembly is gone (or whose token no longer matches) offers to remove itself. + /// + [Export] + [Shared] + public sealed class BookmarkNavigator + { + readonly BookmarkManager manager; + + [ImportingConstructor] + public BookmarkNavigator(BookmarkManager manager) + { + this.manager = manager; + } + + public async Task NavigateToAsync(Bookmark bookmark) + { + var assemblyTree = AppComposition.TryGetExport(); + if (assemblyTree?.AssemblyList is not { } list) + return; + + var loaded = list.FindAssembly(bookmark.FileName); + if (loaded == null) + { + // Only the navigate action (not startup loading) reaches disk; a vanished file prompts. + if (!File.Exists(bookmark.FileName)) + { + await OfferRemoveAsync(bookmark); + return; + } + loaded = list.OpenAssembly(bookmark.FileName); + } + + if (await loaded.GetMetadataFileOrNullAsync() == null + || loaded.GetTypeSystemOrNull()?.MainModule is not MetadataModule mainModule) + { + await OfferRemoveAsync(bookmark); + return; + } + + IEntity? entity = null; + try + { + entity = mainModule.ResolveEntity(MetadataTokens.EntityHandle((int)bookmark.Token)); + } + catch + { + // A malformed token throws inside the resolver; treated as "not found" below. + } + + // The assembly is present, but the token no longer resolves -- e.g. the file on disk was + // rebuilt and the tokens shifted. That is not the "assembly missing" case, so abort + // quietly instead of nagging with the removal dialog. + if (entity == null) + return; + + // Find the nearest navigable tree node: the entity itself, or the closest enclosing type + // that has one. Compiler-generated members (local functions, lambdas, their display + // classes) have no node of their own, so walk up to the user-visible type containing them. + var node = assemblyTree.FindTreeNode(entity); + for (var type = entity.DeclaringTypeDefinition; node == null && type != null; type = type.DeclaringTypeDefinition) + node = assemblyTree.FindTreeNode(type); + if (node == null) + return; + + // Hand the target line to the text view: it positions the caret once this node's + // document (and its IL-offset map) has landed. + if (AppComposition.TryGetExport()?.ActiveDecompilerTab is { } tab) + tab.PendingBookmark = bookmark; + assemblyTree.SelectNode(node); + } + + async Task OfferRemoveAsync(Bookmark bookmark) + { + if (await BookmarkDialogs.ConfirmRemoveMissingAsync()) + manager.Remove(bookmark); + } + } +} diff --git a/ILSpy/Bookmarks/BookmarksPane.axaml b/ILSpy/Bookmarks/BookmarksPane.axaml new file mode 100644 index 0000000000..49c649ae81 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarksPane.axaml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ILSpy/Bookmarks/BookmarksPane.axaml.cs b/ILSpy/Bookmarks/BookmarksPane.axaml.cs new file mode 100644 index 0000000000..1503a5af73 --- /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); + e.Handled = true; + } + } + } +} diff --git a/ILSpy/Bookmarks/BookmarksPaneModel.cs b/ILSpy/Bookmarks/BookmarksPaneModel.cs new file mode 100644 index 0000000000..a8c7d4c7f0 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarksPaneModel.cs @@ -0,0 +1,162 @@ +// 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 Toggle() => ActiveTab?.ToggleBookmarkAtCaret?.Invoke(); + + [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; + } + manager.Import(path, mode); + } + + 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]); + } + + /// + /// 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..4cd184aa77 --- /dev/null +++ b/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs @@ -0,0 +1,40 @@ +// 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 + /// actually hold a bookmark (a statement or a definition); the text view decides eligibility. + /// + [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 && view.CanToggleBookmarkAtRightClick; + + public bool IsEnabled(TextViewContext context) => true; + + public void Execute(TextViewContext context) => context.TextView?.ToggleBookmarkAtRightClick(); + } +} 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..fd75c10cd8 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; } diff --git a/ILSpy/Images.cs b/ILSpy/Images.cs index 7abd585972..66082e5bb9 100644 --- a/ILSpy/Images.cs +++ b/ILSpy/Images.cs @@ -86,6 +86,16 @@ 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 they can't use nameof; "Boomark.Disable" is the + // asset's actual — misspelled — file name). + public static readonly IImage Bookmark = LoadSvg(nameof(Bookmark)); + public static readonly IImage BookmarkDisable = LoadSvg("Boomark.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..ea32f0f0ee 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,160 @@ 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 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..a639f26cbb 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -165,6 +165,57 @@ 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... + + + 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..1e1dbd0019 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,20 @@ void ResetProgress() /// public DecompilerTextViewState? PendingViewState { get; set; } + /// + /// A bookmark to scroll to once this document is shown. Set by bookmark navigation before the + /// target node is decompiled; the text view computes the line and positions the caret after + /// the new document lands (and clears this), mirroring . + /// + [ObservableProperty] + private Bookmarks.Bookmark? pendingBookmark; + + /// Toggles a bookmark on the caret line. Set by the text view; used by the bookmarks pane toolbar. + public System.Action? ToggleBookmarkAtCaret { 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 +504,7 @@ async Task DecompileAsync() Foldings = null; References = null; DefinitionLookup = null; + DebugInfo = null; UIElements = null; Text = string.Empty; IsDecompiling = false; @@ -711,6 +734,7 @@ void ApplyOutput(AvaloniaEditTextOutput output, string syntaxExtension, string t Foldings = output.Foldings; References = output.References; DefinitionLookup = output.DefinitionLookup; + DebugInfo = new Bookmarks.DecompiledDebugInfo(output.MethodDebugInfos); UIElements = output.UIElements; Text = text; } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 85a4a8dd57..d7f2cf3006 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -93,6 +93,8 @@ 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; + int lastRightClickedLine = -1; + Bookmarks.BookmarkMargin? bookmarkMargin; IReadOnlyList contextMenuEntries = Array.Empty(); Popup richPopup = null!; double distanceToPopupLimit; @@ -138,6 +140,14 @@ 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); } void OnPreviewKeyDownForOmnibar(object? sender, KeyEventArgs e) @@ -606,6 +616,7 @@ void OnTextViewPointerPressedForContextMenu(object? sender, PointerPressedEventA if (!e.GetCurrentPoint(Editor.TextArea.TextView).Properties.IsRightButtonPressed) return; var pos = GetPositionFromPointer(e); + lastRightClickedLine = pos?.Line ?? -1; if (pos == null) { lastRightClickedSegment = null; @@ -648,6 +659,154 @@ 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" }; + + Bookmarks.Bookmark? CreateBookmarkForLine(int line) + { + if (!ShowsBookmarkableCode || DataContext is not DecompilerTabPageModel model) + return null; + return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line); + } + + /// Whether is a statement or definition line that can hold a bookmark. + internal bool CanToggleBookmarkAtLine(int line) => CreateBookmarkForLine(line) != null; + + /// Adds or removes a bookmark on ; a no-op for non-anchorable lines. + internal void ToggleBookmarkAtLine(int line) + { + if (CreateBookmarkForLine(line) is { } candidate) + BookmarkManager?.Toggle(candidate); + } + + /// True when the line under the last right-click can hold a bookmark; drives the context-menu entry. + internal bool CanToggleBookmarkAtRightClick => lastRightClickedLine >= 1 && CanToggleBookmarkAtLine(lastRightClickedLine); + + /// Toggles a bookmark on the line under the last right-click (the context-menu target). + internal void ToggleBookmarkAtRightClick() + { + if (lastRightClickedLine >= 1) + ToggleBookmarkAtLine(lastRightClickedLine); + } + + 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; + } + } + + // Wired onto the model so the bookmarks-pane toolbar can act on the active document. + void ToggleBookmarkAtCaret() => ToggleBookmarkAtLine(Editor.TextArea.Caret.Line); + + // 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) + { + if (model.PendingBookmark is not { } bookmark) + return; + if (GetLineForBookmark(bookmark) is { } line) + { + ScrollToLine(line); + model.PendingBookmark = null; + } + } + + void ScrollToLine(int line) + { + line = Math.Clamp(line, 1, Editor.Document.LineCount); + Editor.TextArea.Caret.Offset = Editor.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(() => { + CenterLineInView(line); + LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line); + bookmarkMargin?.PulseLine(line); + }, DispatcherPriority.Background); + } + + // 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(int line) + { + if (EditorScrollViewer is not { } scrollViewer) + return; + var textView = Editor.TextArea.TextView; + 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 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) + { + 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 (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName + && method.TryGetLineForOffset(bookmark.ILOffset, out var bodyLine)) + return bodyLine; + } + return null; + } + + if (model.References is { } references) + { + foreach (var segment in references) + { + if (segment.IsDefinition && segment.Reference is IEntity entity + && (uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token + && entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName) + return Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber; + } + } + return null; + } + + #endregion + protected override void OnDataContextChanged(System.EventArgs e) { base.OnDataContextChanged(e); @@ -658,6 +817,8 @@ protected override void OnDataContextChanged(System.EventArgs e) { previous.PropertyChanged -= OnModelPropertyChanged; previous.CaptureViewState = null; + previous.ToggleBookmarkAtCaret = null; + previous.NavigateBookmarkInFile = null; } boundModel = DataContext as DecompilerTabPageModel; @@ -668,6 +829,9 @@ 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 actions that operate on the active document route through these. + model.ToggleBookmarkAtCaret = ToggleBookmarkAtCaret; + model.NavigateBookmarkInFile = NavigateBookmarkInFile; 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). @@ -723,6 +887,12 @@ void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { ApplyHighlightedReference(m); } + // A bookmark navigation that lands on the already-open document (no re-decompile) scrolls here. + else if (sender is DecompilerTabPageModel bm + && e.PropertyName == nameof(DecompilerTabPageModel.PendingBookmark)) + { + ApplyPendingBookmark(bm); + } } void ApplyHighlightedReference(DecompilerTabPageModel model) @@ -774,6 +944,9 @@ 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. + ApplyPendingBookmark(model); + SwapCustomElementGenerators(model.CustomElementGenerators); Editor.TextArea.TextView.Redraw(); diff --git a/ILSpy/TextView/LineHighlightAdorner.cs b/ILSpy/TextView/LineHighlightAdorner.cs new file mode 100644 index 0000000000..6834889ef9 --- /dev/null +++ b/ILSpy/TextView/LineHighlightAdorner.cs @@ -0,0 +1,106 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Diagnostics; + +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); + + 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() }, From 2f65aa6dac815e4f3316330734b162e0b9da5f34 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 17 Jun 2026 18:10:44 +0200 Subject: [PATCH 02/13] Address bookmarks review: lifecycle and sentinel fixes Tidies up the bookmarks feature in response to review feedback: - BookmarkMargin subscribed to the shared BookmarkManager.Changed event in its constructor and never unsubscribed, so the singleton kept a closed tab's margin (and its pulse timer) alive. The subscription now follows the margin's time in the visual tree. - LineHighlightAdorner.DisplayLineHighlight always added a fresh renderer with its own timers; navigating repeatedly within the highlight lifetime stacked them. Existing highlights are now dismissed first. - ApplyOutput always built a new DecompiledDebugInfo, contradicting the property's documented contract (null for non-C#, the Empty sentinel when C# yielded no methods). It now honors that contract. - The throwaway StringWriter used for sequence-point capture is disposed. The MemberName doc comment still described a stale-token guard that was deliberately removed: a token can resolve to a compiler-generated member (e.g. a local function) whose name differs from the stored display name, and a name check would wrongly reject a valid navigation. Comment fixed to match the actual token-only navigation. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy/Bookmarks/Bookmark.cs | 7 ++++--- ILSpy/Bookmarks/BookmarkMargin.cs | 20 ++++++++++++++++++-- ILSpy/TextView/DecompilerTabPageModel.cs | 6 +++++- ILSpy/TextView/LineHighlightAdorner.cs | 6 ++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/ILSpy/Bookmarks/Bookmark.cs b/ILSpy/Bookmarks/Bookmark.cs index d2c4c59561..2b5999e767 100644 --- a/ILSpy/Bookmarks/Bookmark.cs +++ b/ILSpy/Bookmarks/Bookmark.cs @@ -65,9 +65,10 @@ public sealed partial class Bookmark : ObservableObject public int ILOffset { get; init; } /// - /// Display name of the anchored member, shown in the pane's "Location" column and used as a - /// stale-token guard: if a reloaded module's token no longer resolves to this member, the - /// bookmark is treated as missing rather than silently pointing at the wrong code. + /// Display name of the anchored member, shown in the pane's "Location" column. Navigation + /// resolves purely by token and does not compare this name: a token can legitimately point at + /// a compiler-generated member (e.g. a local function) whose resolved name differs from this + /// stored display name, and a name check would wrongly reject that valid navigation. /// public required string MemberName { get; init; } diff --git a/ILSpy/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs index 6946f7fb09..f92f32d200 100644 --- a/ILSpy/Bookmarks/BookmarkMargin.cs +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -53,12 +53,28 @@ public BookmarkMargin(TextView.DecompilerTextView owner) { this.owner = owner; manager = AppEnv.AppComposition.TryGetExport(); - if (manager != null) - manager.Changed += OnBookmarksChanged; pulseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; pulseTimer.Tick += OnPulseTick; } + // The manager is a shared singleton, so its Changed event would otherwise keep a closed tab's + // margin (and its pulse timer) alive. Track the subscription to the margin's time in the tree. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + if (manager != null) + manager.Changed += OnBookmarksChanged; + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + if (manager != null) + manager.Changed -= OnBookmarksChanged; + pulseTimer.Stop(); + pulseLine = -1; + } + void OnBookmarksChanged(object? sender, EventArgs e) => InvalidateVisual(); /// Plays the one-shot bounce on the bookmark glyph at . diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 1e1dbd0019..bda0975703 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -734,7 +734,11 @@ void ApplyOutput(AvaloniaEditTextOutput output, string syntaxExtension, string t Foldings = output.Foldings; References = output.References; DefinitionLookup = output.DefinitionLookup; - DebugInfo = new Bookmarks.DecompiledDebugInfo(output.MethodDebugInfos); + 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/LineHighlightAdorner.cs b/ILSpy/TextView/LineHighlightAdorner.cs index 6834889ef9..a205fc04a3 100644 --- a/ILSpy/TextView/LineHighlightAdorner.cs +++ b/ILSpy/TextView/LineHighlightAdorner.cs @@ -18,6 +18,7 @@ using System; using System.Diagnostics; +using System.Linq; using AvaloniaEdit.Editing; using AvaloniaEdit.Rendering; @@ -89,6 +90,11 @@ 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(); From e5af7f5202beb8c221dfef11aebfcad685a3468e Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Sat, 27 Jun 2026 07:08:06 +0200 Subject: [PATCH 03/13] Complete bookmark backlog follow-ups Bookmarks now capture and restore the requested decompiler view state, support rendered-line fallback anchors, expose hover and pane location affordances, route bookmark context-menu actions through the clicked offset, and update the JSON sidecar under a mutex so concurrent instances do not overwrite unrelated bookmark edits. The plan file records the completed checklist and the remaining manual smoke-test gap. Assisted-by: CodeAlta:gpt-5.5:CodeAlta --- .../Bookmarks/BookmarkAnchoringTests.cs | 10 +- .../Bookmarks/BookmarkContextMenuTests.cs | 138 +++++++++++++++ ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs | 137 +++++++++++++++ ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs | 129 +++++++++++++- .../Bookmarks/BookmarksPaneStructureTests.cs | 79 +++++++++ ILSpy/App.axaml | 4 + ILSpy/AppEnv/ConfigurationFiles.cs | 4 +- ILSpy/Assets/Icons/Bookmark.GroupDisable.svg | 1 - ILSpy/Assets/Icons/Bookmark.Next.Folder.svg | 1 - .../Assets/Icons/Bookmark.Previous.Folder.svg | 42 ----- ILSpy/Assets/Icons/Bookmark.Window.svg | 1 - ILSpy/Bookmarks/Bookmark.cs | 56 +++++- ILSpy/Bookmarks/BookmarkAnchoring.cs | 48 +++++- ILSpy/Bookmarks/BookmarkManager.cs | 160 ++++++++++++++---- ILSpy/Bookmarks/BookmarkMargin.cs | 115 +++++++++++-- ILSpy/Bookmarks/BookmarkNavigator.cs | 43 ++--- ILSpy/Bookmarks/BookmarkViewState.cs | 100 +++++++++++ ILSpy/Bookmarks/BookmarksPane.axaml | 129 ++++++++------ ILSpy/Bookmarks/BookmarksPaneModel.cs | 3 - .../ToggleBookmarkContextMenuEntry.cs | 12 +- ILSpy/Images.cs | 2 +- ILSpy/TextView/DecompilerTabPageModel.cs | 3 - ILSpy/TextView/DecompilerTextView.axaml.cs | 148 +++++++++++++--- 23 files changed, 1146 insertions(+), 219 deletions(-) create mode 100644 ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs create mode 100644 ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs create mode 100644 ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs delete mode 100644 ILSpy/Assets/Icons/Bookmark.GroupDisable.svg delete mode 100644 ILSpy/Assets/Icons/Bookmark.Next.Folder.svg delete mode 100644 ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg delete mode 100644 ILSpy/Assets/Icons/Bookmark.Window.svg create mode 100644 ILSpy/Bookmarks/BookmarkViewState.cs diff --git a/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs index a2581e4047..4e1a7a5aad 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs @@ -70,7 +70,13 @@ public async Task Decompiled_type_offers_both_body_and_token_anchors() 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. - BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, 1).Should().BeNull(); + + // 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/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs new file mode 100644 index 0000000000..5b50d41e1a --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -0,0 +1,137 @@ +// 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.AppEnv; +using ICSharpCode.ILSpy.Bookmarks; +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"); + } +} diff --git a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs index b96e2f0f7e..ad782a76ef 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs @@ -70,7 +70,7 @@ BookmarkManager NewManagerInFreshDir() return new BookmarkManager(); } - static Bookmark MakeBookmark(uint token, BookmarkKind kind = BookmarkKind.Token, int ilOffset = 0, string name = "") + 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", @@ -79,7 +79,9 @@ static Bookmark MakeBookmark(uint token, BookmarkKind kind = BookmarkKind.Token, Token = token, Kind = kind, ILOffset = ilOffset, + LineNumber = lineNumber, MemberName = "Sample.Type.Member", + LocationNodeName = locationNodeName, }; [Test] @@ -116,6 +118,17 @@ public void Body_anchors_with_different_offsets_are_distinct() 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 Saved_bookmarks_reload_in_a_fresh_manager() { @@ -132,6 +145,120 @@ public void Saved_bookmarks_reload_in_a_fresh_manager() 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() { 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 [Export] [Shared] @@ -55,6 +56,7 @@ public sealed class BookmarkManager { const string FileName = "ILSpy.Bookmarks.json"; const int CurrentVersion = 1; + const string BookmarksFileMutex = "81FC41D7-A7FA-4386-B8DE-E75BA5355A35"; static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, @@ -83,21 +85,29 @@ public BookmarkManager() public bool Toggle(Bookmark bookmark) { ArgumentNullException.ThrowIfNull(bookmark); - var existing = Bookmarks.FirstOrDefault(b => b.AnchorKey == bookmark.AnchorKey); - if (existing != null) - { - Bookmarks.Remove(existing); - return false; - } - if (string.IsNullOrEmpty(bookmark.Name)) - bookmark.Name = NextDefaultName(); - Bookmarks.Add(bookmark); - return true; + bool added = false; + UpdateSavedBookmarks(bookmarks => { + var existing = bookmarks.FirstOrDefault(b => b.AnchorKey == bookmark.AnchorKey); + if (existing != null) + { + bookmarks.Remove(existing); + return; + } + if (string.IsNullOrEmpty(bookmark.Name)) + bookmark.Name = NextDefaultName(bookmarks); + bookmarks.Add(bookmark); + added = true; + }); + return added; } - public void Remove(Bookmark bookmark) => Bookmarks.Remove(bookmark); + public void Remove(Bookmark bookmark) + { + ArgumentNullException.ThrowIfNull(bookmark); + UpdateSavedBookmarks(bookmarks => bookmarks.RemoveAll(b => b.AnchorKey == bookmark.AnchorKey)); + } - public void Clear() => Bookmarks.Clear(); + public void Clear() => UpdateSavedBookmarks(bookmarks => bookmarks.Clear()); /// Writes the current list to in the on-disk JSON format. public void Export(string path) @@ -118,20 +128,23 @@ public void Import(string path, BookmarkImportMode mode) if (imported.Count == 0 && mode == BookmarkImportMode.Merge) return; - RunBatch(() => { + UpdateSavedBookmarks(bookmarks => { if (mode == BookmarkImportMode.Replace) - Bookmarks.Clear(); - var present = new HashSet(Bookmarks.Select(b => b.AnchorKey)); + bookmarks.Clear(); + var present = new HashSet(bookmarks.Select(b => b.AnchorKey)); foreach (var b in imported) { if (present.Add(b.AnchorKey)) - Bookmarks.Add(b); + bookmarks.Add(b); } }); } /// Persists the current list to the standard sidecar location. - public void Save() => WriteTo(ConfigurationFiles.GetPath(FileName)); + public void Save() => UpdateSavedBookmarks(bookmarks => { + bookmarks.Clear(); + bookmarks.AddRange(Bookmarks); + }); void Load() { @@ -150,25 +163,28 @@ void Load() } } - // Applies a multi-step mutation without persisting/raising per step, then persists once. - void RunBatch(Action action) + // Replaces the observable collection without treating each item as a user edit. + void ReplaceLiveBookmarks(List bookmarks) { suppressPersist = true; try { - action(); + Bookmarks.Clear(); + foreach (var bookmark in bookmarks) + Bookmarks.Add(bookmark); } finally { suppressPersist = false; } - PersistAndNotify(); } - string NextDefaultName() + string NextDefaultName() => NextDefaultName(Bookmarks); + + static string NextDefaultName(IEnumerable bookmarks) { // Pick the lowest unused "Bookmark{n}" so names stay stable and unsurprising. - var used = new HashSet(Bookmarks.Select(b => b.Name)); + var used = new HashSet(bookmarks.Select(b => b.Name)); for (int i = 0; ; i++) { var candidate = "Bookmark" + i; @@ -192,7 +208,12 @@ void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) PersistAndNotify(); } - void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e) => PersistAndNotify(); + void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (sender is Bookmark bookmark + && (e.PropertyName == nameof(Bookmark.Name) || e.PropertyName == nameof(Bookmark.Enabled))) + PersistBookmarkEdit(bookmark); + } void PersistAndNotify() { @@ -202,16 +223,46 @@ void PersistAndNotify() Changed?.Invoke(this, EventArgs.Empty); } + void PersistBookmarkEdit(Bookmark bookmark) + { + if (suppressPersist) + return; + UpdateSavedBookmarks(bookmarks => { + int index = bookmarks.FindIndex(b => b.AnchorKey == bookmark.AnchorKey); + if (index >= 0) + bookmarks[index] = bookmark; + else + bookmarks.Add(bookmark); + }); + } + + void UpdateSavedBookmarks(Action> update) + { + ArgumentNullException.ThrowIfNull(update); + try + { + var path = ConfigurationFiles.GetPath(FileName); + List bookmarks; + using (new MutexProtector(BookmarksFileMutex)) + { + bookmarks = ReadFrom(path); + update(bookmarks); + WriteListTo(path, bookmarks); + } + ReplaceLiveBookmarks(bookmarks); + Changed?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Debug.WriteLine($"[BookmarkManager] Update failed: {ex}"); + } + } + void WriteTo(string path) { try { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - var file = new BookmarkFile(CurrentVersion, Bookmarks.Select(BookmarkRecord.From).ToList()); - using var stream = System.IO.File.Create(path); - JsonSerializer.Serialize(stream, file, JsonOptions); + WriteListTo(path, Bookmarks); } catch (Exception ex) { @@ -219,6 +270,16 @@ void WriteTo(string path) } } + static void WriteListTo(string path, IEnumerable bookmarks) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + var file = new BookmarkFile(CurrentVersion, bookmarks.Select(BookmarkRecord.From).ToList()); + using var stream = System.IO.File.Create(path); + JsonSerializer.Serialize(stream, file, JsonOptions); + } + static List ReadFrom(string path) { if (!System.IO.File.Exists(path)) @@ -246,10 +307,12 @@ sealed record BookmarkFile(int Version, List Bookmarks); sealed record BookmarkRecord( string Name, bool Enabled, string FileName, string AssemblyFullName, - string ModuleName, uint Token, BookmarkKind Kind, int ILOffset, string MemberName) + string ModuleName, uint Token, BookmarkKind Kind, int ILOffset, int LineNumber, + string MemberName, string? LocationNodeName, BookmarkViewState? ViewState) { public static BookmarkRecord From(Bookmark b) => new( - b.Name, b.Enabled, b.FileName, b.AssemblyFullName, b.ModuleName, b.Token, b.Kind, b.ILOffset, b.MemberName); + b.Name, b.Enabled, b.FileName, b.AssemblyFullName, b.ModuleName, b.Token, b.Kind, + b.ILOffset, b.LineNumber, b.MemberName, b.LocationNodeName, b.ViewState); public Bookmark ToBookmark() => new() { Name = Name, @@ -260,8 +323,37 @@ sealed record BookmarkRecord( Token = Token, Kind = Kind, ILOffset = ILOffset, + LineNumber = LineNumber, MemberName = MemberName, + LocationNodeName = LocationNodeName, + ViewState = ViewState, }; } + + sealed class MutexProtector : IDisposable + { + readonly Mutex mutex; + + public MutexProtector(string name) + { + 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/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs index f92f32d200..b0fad444d3 100644 --- a/ILSpy/Bookmarks/BookmarkMargin.cs +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -21,6 +21,7 @@ using System.Diagnostics; using Avalonia; +using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; using Avalonia.Threading; @@ -39,40 +40,65 @@ namespace ICSharpCode.ILSpy.Bookmarks public sealed class BookmarkMargin : AbstractMargin { const double IconSize = 16; + static readonly IBrush FallbackBackground = new SolidColorBrush(Color.FromRgb(0xF3, 0xF0, 0xD0)); + static readonly IBrush FallbackBorder = new SolidColorBrush(Color.FromRgb(0xC8, 0xCD, 0xD3)); // One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs. const double PulseAmount = 0.35; const int PulseDurationMs = 600; readonly TextView.DecompilerTextView owner; - readonly BookmarkManager? manager; + BookmarkManager? manager; readonly DispatcherTimer pulseTimer; readonly Stopwatch pulseElapsed = new(); + bool isAttached; + bool subscribedToManager; int pulseLine = -1; + int hoverLine = -1; public BookmarkMargin(TextView.DecompilerTextView owner) { this.owner = owner; - manager = AppEnv.AppComposition.TryGetExport(); pulseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; pulseTimer.Tick += OnPulseTick; } + BookmarkManager? Manager { + get { + manager ??= AppEnv.AppComposition.TryGetExport(); + EnsureManagerSubscription(); + return manager; + } + } + + void EnsureManagerSubscription() + { + if (!isAttached || subscribedToManager || manager == null) + return; + manager.Changed += OnBookmarksChanged; + subscribedToManager = true; + } + // The manager is a shared singleton, so its Changed event would otherwise keep a closed tab's // margin (and its pulse timer) alive. Track the subscription to the margin's time in the tree. protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) { base.OnAttachedToVisualTree(e); - if (manager != null) - manager.Changed += OnBookmarksChanged; + isAttached = true; + _ = Manager; } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); - if (manager != null) + isAttached = false; + if (manager != null && subscribedToManager) + { manager.Changed -= OnBookmarksChanged; + subscribedToManager = false; + } pulseTimer.Stop(); pulseLine = -1; + hoverLine = -1; } void OnBookmarksChanged(object? sender, EventArgs e) => InvalidateVisual(); @@ -107,7 +133,10 @@ double CurrentPulseScale() public override void Render(DrawingContext drawingContext) { + DrawBackground(drawingContext); + var textView = TextView; + var manager = Manager; if (manager == null || textView == null || !textView.VisualLinesValid) return; @@ -116,19 +145,32 @@ public override void Render(DrawingContext drawingContext) var glyphByLine = new Dictionary(); foreach (var bookmark in manager.Bookmarks) { - if (owner.GetLineForBookmark(bookmark) is { } line) + if (owner.GetLineForBookmark(bookmark, updateRenderedLine: false) is { } line) + { + if (bookmark.LineNumber != line) + { + Dispatcher.UIThread.Post(() => bookmark.UpdateRenderedLineNumber(line), DispatcherPriority.Background); + } glyphByLine[line] = bookmark.Enabled ? Images.Bookmark : Images.BookmarkDisable; + } } - if (glyphByLine.Count == 0) - return; - foreach (var visualLine in textView.VisualLines) { int lineNumber = visualLine.FirstDocumentLine.LineNumber; - if (!glyphByLine.TryGetValue(lineNumber, out var glyph)) + bool hasGlyph = glyphByLine.TryGetValue(lineNumber, out var glyph); + bool isHoverPreview = !hasGlyph && lineNumber == hoverLine; + if (!hasGlyph && !isHoverPreview) continue; double top = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.TextTop) - textView.VerticalOffset; var rect = new Rect(0, top, IconSize, IconSize); + glyph ??= Images.Bookmark; + + if (isHoverPreview) + { + using (drawingContext.PushOpacity(0.35)) + drawingContext.DrawImage(glyph, rect); + continue; + } double scale = lineNumber == pulseLine ? CurrentPulseScale() : 1.0; if (scale != 1.0) @@ -146,18 +188,71 @@ public override void Render(DrawingContext drawingContext) } } + void DrawBackground(DrawingContext drawingContext) + { + // The filled background keeps the empty gutter hit-testable so the first click can + // create a bookmark and so hover previews work before any glyph has been drawn. + var bounds = new Rect(Bounds.Size); + drawingContext.DrawRectangle(GetBrush("ILSpy.BookmarkGutterBackground", FallbackBackground), null, bounds); + var border = GetBrush("ILSpy.BookmarkGutterBorder", FallbackBorder); + double x = Math.Max(0, Bounds.Width - 0.5); + drawingContext.DrawLine(new Pen(border, 1), new Point(x, 0), new Point(x, Bounds.Height)); + } + + IBrush GetBrush(string key, IBrush fallback) + { + return this.TryFindResource(key, ActualThemeVariant, out var resource) && resource is IBrush brush + ? brush + : fallback; + } + protected override void OnPointerPressed(PointerPressedEventArgs e) { base.OnPointerPressed(e); var textView = TextView; if (e.Handled || textView == null) return; + // Only the left button toggles. A right- or middle-click in the gutter must not + // add or remove a bookmark: it makes accidental deletion easy and would clash with + // a future gutter context menu. + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; double y = e.GetPosition(textView).Y + textView.VerticalOffset; var visualLine = textView.GetVisualLineFromVisualTop(y); if (visualLine == null) return; owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber); + InvalidateVisual(); e.Handled = true; } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + var textView = TextView; + int newHoverLine = -1; + if (textView != null) + { + double y = e.GetPosition(textView).Y + textView.VerticalOffset; + var visualLine = textView.GetVisualLineFromVisualTop(y); + if (visualLine != null && owner.CanToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber)) + newHoverLine = visualLine.FirstDocumentLine.LineNumber; + } + SetHoverLine(newHoverLine); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + SetHoverLine(-1); + } + + void SetHoverLine(int line) + { + if (hoverLine == line) + return; + hoverLine = line; + InvalidateVisual(); + } } } diff --git a/ILSpy/Bookmarks/BookmarkNavigator.cs b/ILSpy/Bookmarks/BookmarkNavigator.cs index 02e9fe8b4c..d12892459d 100644 --- a/ILSpy/Bookmarks/BookmarkNavigator.cs +++ b/ILSpy/Bookmarks/BookmarkNavigator.cs @@ -18,10 +18,9 @@ using System.Composition; using System.IO; -using System.Reflection.Metadata.Ecma335; +using System.Linq; using System.Threading.Tasks; -using ICSharpCode.Decompiler.TypeSystem; using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.AssemblyTree; using ICSharpCode.ILSpy.Docking; @@ -30,9 +29,9 @@ namespace ICSharpCode.ILSpy.Bookmarks { /// /// Navigates to a bookmark's location: it makes sure the assembly is loaded (loading it from - /// disk if it dropped out of the list), resolves the token to an entity, selects the matching - /// tree node, and asks the text view to scroll to the exact line once the document is shown. A - /// bookmark whose assembly is gone (or whose token no longer matches) offers to remove itself. + /// disk if it dropped out of the list), restores the captured tree node, and asks the text view + /// to scroll to the exact line once the document is shown. A bookmark whose assembly is gone + /// offers to remove itself. /// [Export] [Shared] @@ -64,35 +63,16 @@ public async Task NavigateToAsync(Bookmark bookmark) loaded = list.OpenAssembly(bookmark.FileName); } - if (await loaded.GetMetadataFileOrNullAsync() == null - || loaded.GetTypeSystemOrNull()?.MainModule is not MetadataModule mainModule) + if (await loaded.GetMetadataFileOrNullAsync() == null) { await OfferRemoveAsync(bookmark); return; } - IEntity? entity = null; - try - { - entity = mainModule.ResolveEntity(MetadataTokens.EntityHandle((int)bookmark.Token)); - } - catch - { - // A malformed token throws inside the resolver; treated as "not found" below. - } - - // The assembly is present, but the token no longer resolves -- e.g. the file on disk was - // rebuilt and the tokens shifted. That is not the "assembly missing" case, so abort - // quietly instead of nagging with the removal dialog. - if (entity == null) - return; - - // Find the nearest navigable tree node: the entity itself, or the closest enclosing type - // that has one. Compiler-generated members (local functions, lambdas, their display - // classes) have no node of their own, so walk up to the user-visible type containing them. - var node = assemblyTree.FindTreeNode(entity); - for (var type = entity.DeclaringTypeDefinition; node == null && type != null; type = type.DeclaringTypeDefinition) - node = assemblyTree.FindTreeNode(type); + // Restore the selected tree node captured with the bookmark when it is still present. + var node = bookmark.ViewState?.SelectedTreeNodePath is { Count: > 0 } path + ? assemblyTree.FindNodeByPath(path.ToArray(), returnBestMatch: false) + : null; if (node == null) return; @@ -100,6 +80,11 @@ public async Task NavigateToAsync(Bookmark bookmark) // document (and its IL-offset map) has landed. if (AppComposition.TryGetExport()?.ActiveDecompilerTab is { } tab) tab.PendingBookmark = bookmark; + if (bookmark.ViewState?.RenderedLayoutSettings is { } layoutSettings + && AppComposition.TryGetExport() is { } settingsService) + { + layoutSettings.ApplyTo(settingsService.DisplaySettings); + } assemblyTree.SelectNode(node); } diff --git a/ILSpy/Bookmarks/BookmarkViewState.cs b/ILSpy/Bookmarks/BookmarkViewState.cs new file mode 100644 index 0000000000..65ccec1162 --- /dev/null +++ b/ILSpy/Bookmarks/BookmarkViewState.cs @@ -0,0 +1,100 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Linq; + +using ICSharpCode.ILSpy.Options; +using ICSharpCode.ILSpy.TextView; + +namespace ICSharpCode.ILSpy.Bookmarks +{ + /// A serializable folding range stored inside a bookmark view-state payload. + public sealed record BookmarkFoldingRange(int Start, int End); + + /// Display settings that affect the rendered C# text layout. + public sealed record BookmarkRenderedLayoutSettings( + bool FoldBraces, + bool ExpandMemberDefinitions, + bool ExpandUsingDeclarations, + bool ShowDebugInfo, + bool IndentationUseTabs, + int IndentationSize, + int IndentationTabSize) + { + public static BookmarkRenderedLayoutSettings From(DisplaySettings display) + => new( + display.FoldBraces, + display.ExpandMemberDefinitions, + display.ExpandUsingDeclarations, + display.ShowDebugInfo, + display.IndentationUseTabs, + display.IndentationSize, + display.IndentationTabSize); + + public void ApplyTo(DisplaySettings display) + { + display.FoldBraces = FoldBraces; + display.ExpandMemberDefinitions = ExpandMemberDefinitions; + display.ExpandUsingDeclarations = ExpandUsingDeclarations; + display.ShowDebugInfo = ShowDebugInfo; + display.IndentationUseTabs = IndentationUseTabs; + display.IndentationSize = IndentationSize; + display.IndentationTabSize = IndentationTabSize; + } + } + + /// + /// Serializable editor view state captured with a bookmark. The version is part of the payload so + /// future schema changes can be handled without changing the bookmark file version. + /// + public sealed record BookmarkViewState( + int Version, + int CaretOffset, + double VerticalOffset, + double HorizontalOffset, + int? FoldingChecksum, + IReadOnlyList? ExpandedFoldings, + BookmarkRenderedLayoutSettings? RenderedLayoutSettings = null, + IReadOnlyList? SelectedTreeNodePath = null) + { + public static BookmarkViewState From(DecompilerTextViewState state, DisplaySettings? displaySettings = null, + IReadOnlyList? selectedTreeNodePath = null) + => new( + 1, + state.CaretOffset, + state.VerticalOffset, + state.HorizontalOffset, + state.Foldings?.Checksum, + state.Foldings?.Expanded.Select(r => new BookmarkFoldingRange(r.Start, r.End)).ToArray(), + displaySettings != null ? BookmarkRenderedLayoutSettings.From(displaySettings) : null, + selectedTreeNodePath); + + public DecompilerTextViewState ToDecompilerTextViewState() + { + FoldingsViewState.Snapshot? foldings = null; + if (FoldingChecksum is { } checksum) + { + var expanded = ExpandedFoldings?.Select(r => (r.Start, r.End)).ToArray() + ?? System.Array.Empty<(int Start, int End)>(); + foldings = new FoldingsViewState.Snapshot(expanded, checksum); + } + return new DecompilerTextViewState(CaretOffset, VerticalOffset, HorizontalOffset, foldings); + } + } +} diff --git a/ILSpy/Bookmarks/BookmarksPane.axaml b/ILSpy/Bookmarks/BookmarksPane.axaml index 49c649ae81..2c42e4b4c6 100644 --- a/ILSpy/Bookmarks/BookmarksPane.axaml +++ b/ILSpy/Bookmarks/BookmarksPane.axaml @@ -5,61 +5,86 @@ xmlns:bookmarks="using:ICSharpCode.ILSpy.Bookmarks" xmlns:res="using:ICSharpCode.ILSpy.Properties" xmlns:ilspy="using:ICSharpCode.ILSpy" + xmlns:controls="using:ICSharpCode.ILSpy.Views.Controls" mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="200" x:Class="ICSharpCode.ILSpy.Bookmarks.BookmarksPane" x:DataType="bookmarks:BookmarksPaneModel"> - + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - + Binding="{Binding DisplayLocation}" IsReadOnly="True" /> + + + + + + + diff --git a/ILSpy/Bookmarks/BookmarksPaneModel.cs b/ILSpy/Bookmarks/BookmarksPaneModel.cs index a8c7d4c7f0..6d86583de8 100644 --- a/ILSpy/Bookmarks/BookmarksPaneModel.cs +++ b/ILSpy/Bookmarks/BookmarksPaneModel.cs @@ -71,9 +71,6 @@ public Task ActivateAsync(Bookmark bookmark) return navigator.NavigateToAsync(bookmark); } - [RelayCommand] - void Toggle() => ActiveTab?.ToggleBookmarkAtCaret?.Invoke(); - [RelayCommand] void Next() => NavigateRelative(1); diff --git a/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs b/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs index 4cd184aa77..b8d0558347 100644 --- a/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs +++ b/ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs @@ -24,17 +24,23 @@ namespace ICSharpCode.ILSpy.Bookmarks { /// /// Right-click -> Toggle Bookmark in the decompiled C# view. Only shown on a line that can - /// actually hold a bookmark (a statement or a definition); the text view decides eligibility. + /// 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 && view.CanToggleBookmarkAtRightClick; + => context.TextView is { } view + && context.TextLocation is { } offset + && view.CanToggleBookmarkAtOffset(offset); public bool IsEnabled(TextViewContext context) => true; - public void Execute(TextViewContext context) => context.TextView?.ToggleBookmarkAtRightClick(); + public void Execute(TextViewContext context) + { + if (context.TextView is { } view && context.TextLocation is { } offset) + view.ToggleBookmarkAtOffset(offset); + } } } diff --git a/ILSpy/Images.cs b/ILSpy/Images.cs index 66082e5bb9..e0bedebb47 100644 --- a/ILSpy/Images.cs +++ b/ILSpy/Images.cs @@ -87,7 +87,7 @@ static IImage LoadPng(string name) public static readonly IImage ShowAll = LoadSvg(nameof(ShowAll)); // Bookmarks (file names carry dots, so they can't use nameof; "Boomark.Disable" is the - // asset's actual — misspelled — file name). + // asset's actual -- misspelled -- file name). public static readonly IImage Bookmark = LoadSvg(nameof(Bookmark)); public static readonly IImage BookmarkDisable = LoadSvg("Boomark.Disable"); public static readonly IImage BookmarkNext = LoadSvg("Bookmark.Next"); diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index bda0975703..ecfdc49104 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -245,9 +245,6 @@ void ResetProgress() [ObservableProperty] private Bookmarks.Bookmark? pendingBookmark; - /// Toggles a bookmark on the caret line. Set by the text view; used by the bookmarks pane toolbar. - public System.Action? ToggleBookmarkAtCaret { get; set; } - /// Moves to the next (true) / previous (false) bookmark within this document. Set by the text view. public System.Action? NavigateBookmarkInFile { get; set; } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index d7f2cf3006..645f4d6649 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,7 +97,6 @@ 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; - int lastRightClickedLine = -1; Bookmarks.BookmarkMargin? bookmarkMargin; IReadOnlyList contextMenuEntries = Array.Empty(); Popup richPopup = null!; @@ -616,7 +619,6 @@ void OnTextViewPointerPressedForContextMenu(object? sender, PointerPressedEventA if (!e.GetCurrentPoint(Editor.TextArea.TextView).Properties.IsRightButtonPressed) return; var pos = GetPositionFromPointer(e); - lastRightClickedLine = pos?.Line ?? -1; if (pos == null) { lastRightClickedSegment = null; @@ -673,12 +675,31 @@ void OnContextMenuOpening(object? sender, CancelEventArgs e) { if (!ShowsBookmarkableCode || DataContext is not DecompilerTabPageModel model) return null; - return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line); + var fallbackOwner = model.CurrentNode is IMemberTreeNode memberNode ? memberNode.Member : null; + var bookmark = Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line, + fallbackOwner, GetLocationNodeName(model.CurrentNode)); + if (bookmark != null) + { + var displaySettings = AppComposition.TryGetExport()?.DisplaySettings; + var selectedTreeNodePath = AssemblyTreeModel.GetPathForNode(model.CurrentNode); + bookmark.ViewState = Bookmarks.BookmarkViewState.From(GetCurrentViewState(), displaySettings, selectedTreeNodePath); + } + return bookmark; } - /// Whether is a statement or definition line that can hold a 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) => CreateBookmarkForLine(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. internal void ToggleBookmarkAtLine(int line) { @@ -686,14 +707,11 @@ internal void ToggleBookmarkAtLine(int line) BookmarkManager?.Toggle(candidate); } - /// True when the line under the last right-click can hold a bookmark; drives the context-menu entry. - internal bool CanToggleBookmarkAtRightClick => lastRightClickedLine >= 1 && CanToggleBookmarkAtLine(lastRightClickedLine); - - /// Toggles a bookmark on the line under the last right-click (the context-menu target). - internal void ToggleBookmarkAtRightClick() + internal void ToggleBookmarkAtOffset(int offset) { - if (lastRightClickedLine >= 1) - ToggleBookmarkAtLine(lastRightClickedLine); + if (offset < 0 || offset > Editor.Document.TextLength) + return; + ToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); } void OnBookmarkKeyDown(object? sender, KeyEventArgs e) @@ -705,9 +723,6 @@ void OnBookmarkKeyDown(object? sender, KeyEventArgs e) } } - // Wired onto the model so the bookmarks-pane toolbar can act on the active document. - void ToggleBookmarkAtCaret() => ToggleBookmarkAtLine(Editor.TextArea.Caret.Line); - // 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) @@ -737,32 +752,74 @@ void ApplyPendingBookmark(DecompilerTabPageModel model) return; if (GetLineForBookmark(bookmark) is { } line) { - ScrollToLine(line); + ScrollToLine(line, bookmark.ViewState); model.PendingBookmark = null; } } - void ScrollToLine(int line) + void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) { - line = Math.Clamp(line, 1, Editor.Document.LineCount); - Editor.TextArea.Caret.Offset = Editor.Document.GetLineByNumber(line).Offset; + 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(() => { - CenterLineInView(line); + if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount) + return; + CenterLineInView(document, line); LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line); bookmarkMargin?.PulseLine(line); + if (viewState != null) + RestoreBookmarkViewState(viewState); }, DispatcherPriority.Background); } + void RestoreBookmarkViewState(Bookmarks.BookmarkViewState viewState) + { + var state = viewState.ToDecompilerTextViewState(); + RestoreCurrentFoldings(state.Foldings); + Editor.TextArea.Caret.Offset = Math.Clamp(state.CaretOffset, 0, Editor.Document.TextLength); + if (EditorScrollViewer is { } scrollViewer) + scrollViewer.Offset = new Vector(state.HorizontalOffset, state.VerticalOffset); + } + + void RestoreCurrentFoldings(FoldingsViewState.Snapshot? saved) + { + if (saved == null || activeFoldingManager is not { } manager) + return; + + var current = FoldingsViewState.Capture(manager.AllFoldings); + if (current.Checksum != saved.Value.Checksum) + return; + + foreach (var folding in manager.AllFoldings) + { + bool wasExpanded = false; + foreach (var (start, end) in saved.Value.Expanded) + { + if (start == folding.StartOffset && end == folding.EndOffset) + { + wasExpanded = true; + break; + } + } + folding.IsFolded = !wasExpanded; + } + } + // 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(int line) + 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)); @@ -774,7 +831,7 @@ void CenterLineInView(int line) /// 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) + internal int? GetLineForBookmark(Bookmarks.Bookmark bookmark, bool updateRenderedLine = true) { if (DataContext is not DecompilerTabPageModel { SyntaxExtension: ".cs" } model) return null; @@ -787,11 +844,24 @@ void CenterLineInView(int line) { if (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName && 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) @@ -799,12 +869,42 @@ void CenterLineInView(int line) if (segment.IsDefinition && segment.Reference is IEntity entity && (uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token && entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName) - return Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber; + { + var line = Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber; + if (updateRenderedLine) + bookmark.UpdateRenderedLineNumber(line); + return line; + } } } return null; } + static bool DocumentContainsBookmarkToken(DecompilerTabPageModel model, Bookmarks.Bookmark bookmark) + { + if (model.DebugInfo != null) + { + foreach (var method in model.DebugInfo.Methods) + { + if (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName) + return true; + } + } + + if (model.References != null) + { + foreach (var segment in model.References) + { + if (segment.Reference is IEntity entity + && (uint)MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token + && entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName) + return true; + } + } + + return false; + } + #endregion protected override void OnDataContextChanged(System.EventArgs e) @@ -817,7 +917,6 @@ protected override void OnDataContextChanged(System.EventArgs e) { previous.PropertyChanged -= OnModelPropertyChanged; previous.CaptureViewState = null; - previous.ToggleBookmarkAtCaret = null; previous.NavigateBookmarkInFile = null; } @@ -829,8 +928,7 @@ 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 actions that operate on the active document route through these. - model.ToggleBookmarkAtCaret = ToggleBookmarkAtCaret; + // Bookmarks-pane toolbar navigation actions that operate on the active document route through this. model.NavigateBookmarkInFile = NavigateBookmarkInFile; ApplyDocument(model); // Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the From c02ed29d9f72406a257c6d2939c468de4b86bdab Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 08:06:43 +0200 Subject: [PATCH 04/13] Position bookmark navigation from non-decompiler active content Activating a bookmark only positioned the view when a decompiler tab was already the active content: the pending bookmark was set on DockWorkspace.ActiveDecompilerTab, which is null while a metadata table, the Options page, or the About page is showing. Navigating from there selected and decompiled the node but opened at the top with no line highlight. The pending bookmark now travels to the decompiler model that ShowSelectedNode routes the selection to, and is consumed in the document-apply step alongside the view-state restore -- mirroring PendingViewState -- rather than reacting to a property change. The earlier property-change path fired synchronously during selection, scrolled, then a deferred document apply reset the caret to the top with nothing left to re-apply. For a node that is already decompiled (re-shown after an interlude, where no apply step runs) a ScrollToBookmark callback on the model scrolls the live view directly, mirroring NavigateBookmarkInFile. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Bookmarks/BookmarkNavigationViewTests.cs | 188 ++++++++++++++++++ ILSpy/Bookmarks/BookmarkNavigator.cs | 15 +- ILSpy/Docking/DockWorkspace.cs | 47 +++++ ILSpy/TextView/DecompilerTabPageModel.cs | 19 +- ILSpy/TextView/DecompilerTextView.axaml.cs | 37 +++- 5 files changed, 285 insertions(+), 21 deletions(-) create mode 100644 ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs new file mode 100644 index 0000000000..83a6d64791 --- /dev/null +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs @@ -0,0 +1,188 @@ +// 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"); + } + + static async Task PumpLayoutAsync() + { + for (int i = 0; i < 8; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(25); + } + } +} diff --git a/ILSpy/Bookmarks/BookmarkNavigator.cs b/ILSpy/Bookmarks/BookmarkNavigator.cs index d12892459d..3889f11c41 100644 --- a/ILSpy/Bookmarks/BookmarkNavigator.cs +++ b/ILSpy/Bookmarks/BookmarkNavigator.cs @@ -76,16 +76,21 @@ public async Task NavigateToAsync(Bookmark bookmark) if (node == null) return; - // Hand the target line to the text view: it positions the caret once this node's - // document (and its IL-offset map) has landed. - if (AppComposition.TryGetExport()?.ActiveDecompilerTab is { } tab) - tab.PendingBookmark = bookmark; if (bookmark.ViewState?.RenderedLayoutSettings is { } layoutSettings && AppComposition.TryGetExport() is { } settingsService) { layoutSettings.ApplyTo(settingsService.DisplaySettings); } - assemblyTree.SelectNode(node); + + // Select the node and hand the target line to the decompiler view it lands in. Routing + // through DockWorkspace (rather than setting PendingBookmark on the active decompiler tab + // here) covers the case where the currently active content is not a decompiler tab -- a + // metadata table, the Options page, the About page -- so there is no active decompiler tab + // to position directly. + if (AppComposition.TryGetExport() is { } dockWorkspace) + dockWorkspace.NavigateToBookmark(node, bookmark); + else + assemblyTree.SelectNode(node); } async Task OfferRemoveAsync(Bookmark bookmark) diff --git a/ILSpy/Docking/DockWorkspace.cs b/ILSpy/Docking/DockWorkspace.cs index fd75c10cd8..07d0a3eb1e 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -685,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 @@ -736,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"); @@ -795,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/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index ecfdc49104..397dff4b42 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -238,12 +238,21 @@ void ResetProgress() public DecompilerTextViewState? PendingViewState { get; set; } /// - /// A bookmark to scroll to once this document is shown. Set by bookmark navigation before the - /// target node is decompiled; the text view computes the line and positions the caret after - /// the new document lands (and clears this), mirroring . + /// 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. /// - [ObservableProperty] - private Bookmarks.Bookmark? pendingBookmark; + 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; } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 645f4d6649..45d1195c87 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -748,13 +748,26 @@ void NavigateBookmarkInFile(bool forward) void ApplyPendingBookmark(DecompilerTabPageModel model) { - if (model.PendingBookmark is not { } bookmark) - return; + // 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); - model.PendingBookmark = null; + return true; } + return false; } void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) @@ -918,6 +931,7 @@ protected override void OnDataContextChanged(System.EventArgs e) previous.PropertyChanged -= OnModelPropertyChanged; previous.CaptureViewState = null; previous.NavigateBookmarkInFile = null; + previous.ScrollToBookmark = null; } boundModel = DataContext as DecompilerTabPageModel; @@ -930,6 +944,9 @@ protected override void OnDataContextChanged(System.EventArgs e) 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). @@ -985,12 +1002,6 @@ void OnModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { ApplyHighlightedReference(m); } - // A bookmark navigation that lands on the already-open document (no re-decompile) scrolls here. - else if (sender is DecompilerTabPageModel bm - && e.PropertyName == nameof(DecompilerTabPageModel.PendingBookmark)) - { - ApplyPendingBookmark(bm); - } } void ApplyHighlightedReference(DecompilerTabPageModel model) @@ -1042,8 +1053,12 @@ 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. - ApplyPendingBookmark(model); + // 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); From e9e960b026ecf02cec38dc5c568c893484e41ff0 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 08:13:15 +0200 Subject: [PATCH 05/13] Keep bookmarks intact when a Replace import hits a corrupt file Import read the chosen file through the tolerant sidecar-load path, which maps a missing, empty, or unparseable file all to an empty list. Replace mode then cleared the saved list and wrote the empty result back, so picking a corrupt or non-bookmark JSON file for a Replace import silently destroyed the user's bookmarks. Import now reads through TryReadFrom, which distinguishes a valid (possibly empty) file from a parse/read failure. A failure leaves the live and saved lists untouched and returns false so the pane can report it; a valid empty file still legitimately clears the list in Replace mode. The normal sidecar load keeps its tolerant behavior. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs | 31 +++++++++++++++++ ILSpy/Bookmarks/BookmarkDialogs.cs | 3 ++ ILSpy/Bookmarks/BookmarkManager.cs | 33 +++++++++++++++---- ILSpy/Bookmarks/BookmarksPaneModel.cs | 3 +- ILSpy/Properties/Resources.Designer.cs | 9 +++++ ILSpy/Properties/Resources.resx | 3 ++ 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs index ad782a76ef..8ef6c40a82 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs @@ -309,4 +309,35 @@ public void Import_merge_adds_only_new_anchors() // 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/Bookmarks/BookmarkDialogs.cs b/ILSpy/Bookmarks/BookmarkDialogs.cs index af51fd68aa..7620d6e99f 100644 --- a/ILSpy/Bookmarks/BookmarkDialogs.cs +++ b/ILSpy/Bookmarks/BookmarkDialogs.cs @@ -37,6 +37,9 @@ public static async Task ConfirmRemoveMissingAsync() => "yes" == await ShowChoiceAsync(Resources.Bookmarks, Resources.BookmarkAssemblyMissing, (Resources._Remove, "yes"), (Resources.Cancel, "no")); + public static Task InformImportFailedAsync() + => ShowChoiceAsync(Resources.BookmarkImportTitle, Resources.BookmarkImportFailed, (Resources.OK, "ok")); + public static async Task AskImportModeAsync() { var result = await ShowChoiceAsync(Resources.BookmarkImportTitle, Resources.BookmarkImportReplaceOrMerge, diff --git a/ILSpy/Bookmarks/BookmarkManager.cs b/ILSpy/Bookmarks/BookmarkManager.cs index a77fbca0a1..ad7b743704 100644 --- a/ILSpy/Bookmarks/BookmarkManager.cs +++ b/ILSpy/Bookmarks/BookmarkManager.cs @@ -119,14 +119,18 @@ public void Export(string path) /// /// Loads bookmarks from . In /// only entries whose anchor is not already present are added; in - /// the current list is discarded first. + /// the current list is discarded first. Returns + /// false when the file cannot be read or parsed, leaving the current list untouched -- + /// in particular so a corrupt file picked for a Replace import does not wipe the bookmarks. + /// A valid but empty file is a success and, in Replace mode, legitimately clears the list. /// - public void Import(string path, BookmarkImportMode mode) + public bool Import(string path, BookmarkImportMode mode) { ArgumentNullException.ThrowIfNull(path); - var imported = ReadFrom(path); + if (!TryReadFrom(path, out var imported)) + return false; if (imported.Count == 0 && mode == BookmarkImportMode.Merge) - return; + return true; UpdateSavedBookmarks(bookmarks => { if (mode == BookmarkImportMode.Replace) @@ -138,6 +142,7 @@ public void Import(string path, BookmarkImportMode mode) bookmarks.Add(b); } }); + return true; } /// Persists the current list to the standard sidecar location. @@ -280,24 +285,38 @@ static void WriteListTo(string path, IEnumerable bookmarks) JsonSerializer.Serialize(stream, file, JsonOptions); } + // Tolerant read for the sidecar load path: an empty list whether the file is absent, empty, or + // unreadable. Import goes through TryReadFrom instead, to tell a valid empty file from a failure. static List ReadFrom(string path) { + TryReadFrom(path, out var bookmarks); + return bookmarks; + } + + // Reads and parses the bookmark file. Returns false (with an empty list) when the file is + // missing or cannot be deserialized, so callers that must not destroy data on a bad file -- + // notably a Replace import -- can bail. A valid file (including an empty one) returns true. + static bool TryReadFrom(string path, out List bookmarks) + { + bookmarks = new List(); if (!System.IO.File.Exists(path)) - return new List(); + return false; try { using var stream = System.IO.File.OpenRead(path); var file = JsonSerializer.Deserialize(stream, JsonOptions); - return file?.Bookmarks? + bookmarks = file?.Bookmarks? // A bookmark must reference an assembly file. Entries without one are artifacts // (e.g. a stray empty row) that could never navigate; drop them defensively. .Where(r => !string.IsNullOrEmpty(r.FileName)) .Select(r => r.ToBookmark()).ToList() ?? new List(); + return true; } catch (Exception ex) { Debug.WriteLine($"[BookmarkManager] Load failed: {ex}"); - return new List(); + bookmarks = new List(); + return false; } } diff --git a/ILSpy/Bookmarks/BookmarksPaneModel.cs b/ILSpy/Bookmarks/BookmarksPaneModel.cs index 6d86583de8..53d15c80a4 100644 --- a/ILSpy/Bookmarks/BookmarksPaneModel.cs +++ b/ILSpy/Bookmarks/BookmarksPaneModel.cs @@ -119,7 +119,8 @@ async Task Import() return; mode = chosen; } - manager.Import(path, mode); + if (!manager.Import(path, mode)) + await BookmarkDialogs.InformImportFailedAsync(); } const string BookmarkFileFilter = "Bookmarks (*.json)|*.json|All Files|*.*"; diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index ea32f0f0ee..26cf421960 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -495,6 +495,15 @@ public static string BookmarkImport { } } + /// + /// 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. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index a639f26cbb..16b6051ce4 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -183,6 +183,9 @@ Are you sure you want to continue? Import Bookmarks... + + The selected file could not be read as a bookmark file. Your bookmarks were left unchanged. + Merge From 3d0cad37c4284e8f963b8ff4fd7db46805b6ec7c Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 20:39:09 +0200 Subject: [PATCH 06/13] Show the arrow cursor over the editor's left gutter margins The text area paints the I-beam across its whole surface, so the bookmark icon gutter, line numbers, and folding margin inherited it by walking up the visual tree. Those margins are click targets, not text, so the I-beam read wrong over them. Force the normal arrow there, re-applying as the line-number and folding margins come and go with their display settings. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Editor/MarginCursorTests.cs | 78 ++++++++++++++++++++++ ILSpy/TextView/DecompilerTextView.axaml.cs | 15 +++++ 2 files changed, 93 insertions(+) create mode 100644 ILSpy.Tests/Editor/MarginCursorTests.cs diff --git a/ILSpy.Tests/Editor/MarginCursorTests.cs b/ILSpy.Tests/Editor/MarginCursorTests.cs new file mode 100644 index 0000000000..7868982869 --- /dev/null +++ b/ILSpy.Tests/Editor/MarginCursorTests.cs @@ -0,0 +1,78 @@ +// 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.Controls; +using Avalonia.Headless.NUnit; + +using AvaloniaEdit.Editing; +using AvaloniaEdit.Folding; + +using AwesomeAssertions; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Bookmarks; +using ICSharpCode.ILSpy.Options; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +[TestFixture] +public class MarginCursorTests +{ + // The text area paints the I-beam across its whole surface, so the left gutter margins inherit it + // unless they set their own cursor. They are click targets (toggle a bookmark, fold a region, no-op + // on line numbers), not text, so they must show the normal arrow instead. + [AvaloniaTest] + public async Task Left_Gutter_Margins_Use_The_Arrow_Cursor_Not_The_Editors_I_Beam() + { + var (window, vm) = await TestHarness.BootAsync(1); + + // Show line numbers so that margin is present alongside the bookmark gutter and folding margin. + AppComposition.Current.GetExport().DisplaySettings.ShowLineNumbers = true; + + // A type (not a single method) so the decompiled body carries foldings and the folding margin + // is installed. + 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(); + + var margins = view.Editor.TextArea.LeftMargins; + await Waiters.WaitForAsync(() => margins.OfType().Any(), + description: "the folding margin to be installed for the decompiled type"); + + // All three margins the user meets in the gutter must be present, or the assertion below would + // pass vacuously for a margin that never materialised. + margins.OfType().Should().ContainSingle("the bookmark icon gutter is always present"); + margins.OfType().Should().ContainSingle("line numbers are enabled"); + margins.OfType().Should().ContainSingle("the decompiled type carries foldings"); + + foreach (Control margin in margins) + { + margin.Cursor.Should().BeSameAs(DecompilerTextView.ArrowCursor, + $"the {margin.GetType().Name} is a click target, so it must show the arrow rather than the text I-beam"); + } + } +} diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 45d1195c87..d94bbfc7c1 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -151,6 +151,21 @@ public DecompilerTextView() // 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) From 3533c3ed5fede41b55d32ea1a68d0ef8495f745b Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 23:12:59 +0200 Subject: [PATCH 07/13] Give the bookmark gutter immediate feedback when removing a bookmark Two fixes to the gutter's hover preview, the faint glyph shown on a hovered bookmarkable line: A removal click left the pointer hovering the line it had just cleared, so the hover preview redrew a glyph there immediately and the click looked like a no-op. The just-removed line's preview is now suppressed until the pointer leaves that line; a jitter that stays on the same line keeps it hidden, and a fresh hover after leaving shows it again. The preview also never actually faded: the glyph is an SvgImage, which paints through a custom Skia draw operation that ignores DrawingContext.PushOpacity, so it always rendered fully opaque. The glyph is now rasterized once into a bitmap, which DrawImage does composite at the pushed opacity. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs | 76 ++++++++++++++++++++ ILSpy/Bookmarks/BookmarkMargin.cs | 52 +++++++++++++- ILSpy/TextView/DecompilerTextView.axaml.cs | 10 ++- 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs index 5b50d41e1a..5215c00431 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -134,4 +134,80 @@ public async Task RightClicking_Bookmark_Gutter_Does_Not_Toggle() 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"); + } } diff --git a/ILSpy/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs index b0fad444d3..68a413462c 100644 --- a/ILSpy/Bookmarks/BookmarkMargin.cs +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -24,6 +24,7 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Media; +using Avalonia.Media.Imaging; using Avalonia.Threading; using AvaloniaEdit.Editing; @@ -40,6 +41,9 @@ namespace ICSharpCode.ILSpy.Bookmarks public sealed class BookmarkMargin : AbstractMargin { const double IconSize = 16; + // Faded glyph shown on a hovered, not-yet-bookmarked line to hint the gutter is clickable; + // kept translucent so it reads as a preview, not an actual (opaque) bookmark. + const double HoverPreviewOpacity = 0.5; static readonly IBrush FallbackBackground = new SolidColorBrush(Color.FromRgb(0xF3, 0xF0, 0xD0)); static readonly IBrush FallbackBorder = new SolidColorBrush(Color.FromRgb(0xC8, 0xCD, 0xD3)); // One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs. @@ -54,6 +58,8 @@ public sealed class BookmarkMargin : AbstractMargin bool subscribedToManager; int pulseLine = -1; int hoverLine = -1; + // A line whose hover preview stays hidden after a removal click, until the pointer leaves it. + int suppressedHoverLine = -1; public BookmarkMargin(TextView.DecompilerTextView owner) { @@ -167,8 +173,8 @@ public override void Render(DrawingContext drawingContext) if (isHoverPreview) { - using (drawingContext.PushOpacity(0.35)) - drawingContext.DrawImage(glyph, rect); + using (drawingContext.PushOpacity(HoverPreviewOpacity)) + drawingContext.DrawImage(HoverPreviewGlyph(), rect); continue; } @@ -188,6 +194,24 @@ public override void Render(DrawingContext drawingContext) } } + // A bitmap copy of the bookmark glyph, shared by all gutters, used only for the hover preview. + // SvgImage paints through a custom Skia draw operation that ignores DrawingContext.PushOpacity, + // so the SVG can't be faded directly; a rasterized bitmap is composited at the pushed opacity. + static Bitmap? hoverPreviewGlyph; + + static Bitmap HoverPreviewGlyph() + { + if (hoverPreviewGlyph != null) + return hoverPreviewGlyph; + var source = Images.Bookmark; + // Oversample so the bitmap stays crisp when the gutter draws it at the device scale. + int pixels = (int)(IconSize * 3); + var bitmap = new RenderTargetBitmap(new PixelSize(pixels, pixels), new Vector(96, 96)); + using (var context = bitmap.CreateDrawingContext()) + source.Draw(context, new Rect(source.Size), new Rect(0, 0, pixels, pixels)); + return hoverPreviewGlyph = bitmap; + } + void DrawBackground(DrawingContext drawingContext) { // The filled background keeps the empty gutter hit-testable so the first click can @@ -221,7 +245,15 @@ protected override void OnPointerPressed(PointerPressedEventArgs e) var visualLine = textView.GetVisualLineFromVisualTop(y); if (visualLine == null) return; - owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber); + // A removal click leaves the pointer hovering the line it just cleared. Without this, Render + // would immediately redraw the line's hover-preview glyph, so the click would look like a + // no-op. Suppress the preview on that line until the pointer leaves it (a jitter that stays + // on the same line must not bring it back); a fresh hover after leaving shows it again. + if (!owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber)) + { + suppressedHoverLine = visualLine.FirstDocumentLine.LineNumber; + SetHoverLine(-1); + } InvalidateVisual(); e.Handled = true; } @@ -238,12 +270,23 @@ protected override void OnPointerMoved(PointerEventArgs e) if (visualLine != null && owner.CanToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber)) newHoverLine = visualLine.FirstDocumentLine.LineNumber; } + if (newHoverLine == suppressedHoverLine) + { + // Still on the just-removed line: keep its preview hidden. + newHoverLine = -1; + } + else + { + // The pointer moved onto a different line, so normal hover resumes. + suppressedHoverLine = -1; + } SetHoverLine(newHoverLine); } protected override void OnPointerExited(PointerEventArgs e) { base.OnPointerExited(e); + suppressedHoverLine = -1; SetHoverLine(-1); } @@ -254,5 +297,8 @@ void SetHoverLine(int line) hoverLine = line; InvalidateVisual(); } + + // The line currently drawing a hover-preview glyph, or -1 when none. Exposed for tests. + internal int HoverPreviewLine => hoverLine; } } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index d94bbfc7c1..e3dc78f233 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -715,11 +715,15 @@ internal bool CanToggleBookmarkAtOffset(int offset) return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber); } - /// Adds or removes a bookmark on ; a no-op for non-anchorable lines. - internal void ToggleBookmarkAtLine(int line) + /// + /// 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) - BookmarkManager?.Toggle(candidate); + return BookmarkManager?.Toggle(candidate) ?? false; + return false; } internal void ToggleBookmarkAtOffset(int offset) From 3919fa27724b88ce206e54ee08902c0a069df94b Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 23:20:01 +0200 Subject: [PATCH 08/13] Reconcile the live bookmark list on save instead of rebuilding it Every save reloaded the JSON sidecar into fresh instances and then replaced the whole observable collection with Clear()+Add(). That dropped and re-added every bookmark on each toggle or edit: the bookmarks pane keeps its selection by reference, so it was cleared on every change (leaving the toolbar acting on a stale or null selection), the grid's scroll reset, and a checkbox/name edit made from the grid re-entered the DataGrid mid-edit while its ItemsSource was torn down and rebuilt. Reconcile in place instead: keep the existing instance for any anchor still present (refreshing only its editable Name/Enabled), remove anchors that are gone, and append new ones. A name/enabled edit leaves the membership unchanged, so it now touches the collection not at all; structural changes touch only the affected rows. Instance identity is preserved, so the pane's selection and the gutter's references survive. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs | 32 +++++++++++ ILSpy/Bookmarks/BookmarkManager.cs | 53 ++++++++++++------- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs index 8ef6c40a82..6b142338db 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs @@ -129,6 +129,38 @@ public void Line_anchors_with_different_visible_lines_are_distinct() 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() { diff --git a/ILSpy/Bookmarks/BookmarkManager.cs b/ILSpy/Bookmarks/BookmarkManager.cs index ad7b743704..296aab6280 100644 --- a/ILSpy/Bookmarks/BookmarkManager.cs +++ b/ILSpy/Bookmarks/BookmarkManager.cs @@ -145,12 +145,6 @@ public bool Import(string path, BookmarkImportMode mode) return true; } - /// Persists the current list to the standard sidecar location. - public void Save() => UpdateSavedBookmarks(bookmarks => { - bookmarks.Clear(); - bookmarks.AddRange(Bookmarks); - }); - void Load() { var loaded = ReadFrom(ConfigurationFiles.GetPath(FileName)); @@ -168,15 +162,41 @@ void Load() } } - // Replaces the observable collection without treating each item as a user edit. + // Reconciles the observable collection with the freshly saved list, in place and without + // treating the changes as user edits. Entries already present (matched by AnchorKey) keep their + // existing instance -- so the bookmarks pane's live selection and the gutter's references stay + // valid -- and only their editable Name/Enabled are refreshed; entries gone from the saved list + // are removed and new ones appended. Rebuilding via Clear()+Add() instead would replace every + // instance and drop the pane's selection on each toggle or edit. void ReplaceLiveBookmarks(List bookmarks) { suppressPersist = true; try { - Bookmarks.Clear(); - foreach (var bookmark in bookmarks) - Bookmarks.Add(bookmark); + var live = new Dictionary(); + foreach (var bookmark in Bookmarks) + live[bookmark.AnchorKey] = bookmark; + + var savedKeys = new HashSet(bookmarks.Select(b => b.AnchorKey)); + for (int i = Bookmarks.Count - 1; i >= 0; i--) + { + if (!savedKeys.Contains(Bookmarks[i].AnchorKey)) + Bookmarks.RemoveAt(i); + } + + foreach (var saved in bookmarks) + { + if (live.TryGetValue(saved.AnchorKey, out var existing)) + { + // Setters are equality-checked, so assigning an unchanged value is a no-op. + existing.Name = saved.Name; + existing.Enabled = saved.Enabled; + } + else + { + Bookmarks.Add(saved); + } + } } finally { @@ -198,6 +218,10 @@ static string NextDefaultName(IEnumerable bookmarks) } } + // The collection is only ever mutated under suppressPersist (the initial load and the + // post-write reconcile in ReplaceLiveBookmarks); persistence and the Changed event are owned + // by UpdateSavedBookmarks. This handler's sole job is to keep each bookmark's name/enabled + // edit subscription in step with the live list. void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.OldItems != null) @@ -210,7 +234,6 @@ void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) foreach (Bookmark b in e.NewItems) b.PropertyChanged += OnBookmarkPropertyChanged; } - PersistAndNotify(); } void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e) @@ -220,14 +243,6 @@ void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e) PersistBookmarkEdit(bookmark); } - void PersistAndNotify() - { - if (suppressPersist) - return; - Save(); - Changed?.Invoke(this, EventArgs.Empty); - } - void PersistBookmarkEdit(Bookmark bookmark) { if (suppressPersist) From 80f7a21acecff198bbf5aa106675dd110fb9ef04 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 23:27:15 +0200 Subject: [PATCH 09/13] Scroll bookmark navigation to the re-anchored line, not the saved offset After scrolling to a navigated bookmark, the deferred apply restored the caret/scroll captured when the bookmark was created, which overwrote the centering that had just been computed for the resolved line. A bookmark re-anchors by token / IL offset, so a decompiler-setting change that reflows the C# moves it to a different line than the one saved in its view state; the stale offset then scrolled that line back off-screen, leaving only the highlight playing where it could not be seen. Restore just the captured foldings now -- and before centering, since collapsing or expanding shifts where lines sit -- and let the centered, re-resolved line be the final caret and scroll position. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Bookmarks/BookmarkNavigationViewTests.cs | 55 +++++++++++++++++++ ILSpy/TextView/DecompilerTextView.axaml.cs | 18 +++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs index 83a6d64791..4685b2d012 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs @@ -177,6 +177,61 @@ await Waiters.WaitForAsync(() => ActiveView()?.Editor.TextArea.TextView.Backgrou .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++) diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index e3dc78f233..96aa68971a 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -800,22 +800,20 @@ void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) 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); - if (viewState != null) - RestoreBookmarkViewState(viewState); }, DispatcherPriority.Background); } - void RestoreBookmarkViewState(Bookmarks.BookmarkViewState viewState) - { - var state = viewState.ToDecompilerTextViewState(); - RestoreCurrentFoldings(state.Foldings); - Editor.TextArea.Caret.Offset = Math.Clamp(state.CaretOffset, 0, Editor.Document.TextLength); - if (EditorScrollViewer is { } scrollViewer) - scrollViewer.Offset = new Vector(state.HorizontalOffset, state.VerticalOffset); - } + void RestoreBookmarkFoldings(Bookmarks.BookmarkViewState viewState) + => RestoreCurrentFoldings(viewState.ToDecompilerTextViewState().Foldings); void RestoreCurrentFoldings(FoldingsViewState.Snapshot? saved) { From f1103396b8a066c0cb9e0eb50ebe5559adfe52cf Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 23:31:37 +0200 Subject: [PATCH 10/13] Clean up bookmark nits: HandleExceptions, dead method, asset typo - Route the bookmarks pane's double-click and next/previous navigation through HandleExceptions() rather than discarding the Task with `_ =`, so an exception raised during navigation reaches the global handler instead of vanishing. - Drop the unused parameterless NextDefaultName() overload. - Rename the misspelled Boomark.Disable.svg asset (and its loader string) to Bookmark.Disable.svg. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Icons/{Boomark.Disable.svg => Bookmark.Disable.svg} | 0 ILSpy/Bookmarks/BookmarkManager.cs | 2 -- ILSpy/Bookmarks/BookmarksPane.axaml.cs | 2 +- ILSpy/Bookmarks/BookmarksPaneModel.cs | 2 +- ILSpy/Images.cs | 5 ++--- 5 files changed, 4 insertions(+), 7 deletions(-) rename ILSpy/Assets/Icons/{Boomark.Disable.svg => Bookmark.Disable.svg} (100%) diff --git a/ILSpy/Assets/Icons/Boomark.Disable.svg b/ILSpy/Assets/Icons/Bookmark.Disable.svg similarity index 100% rename from ILSpy/Assets/Icons/Boomark.Disable.svg rename to ILSpy/Assets/Icons/Bookmark.Disable.svg diff --git a/ILSpy/Bookmarks/BookmarkManager.cs b/ILSpy/Bookmarks/BookmarkManager.cs index 296aab6280..aff5d75588 100644 --- a/ILSpy/Bookmarks/BookmarkManager.cs +++ b/ILSpy/Bookmarks/BookmarkManager.cs @@ -204,8 +204,6 @@ void ReplaceLiveBookmarks(List bookmarks) } } - string NextDefaultName() => NextDefaultName(Bookmarks); - static string NextDefaultName(IEnumerable bookmarks) { // Pick the lowest unused "Bookmark{n}" so names stay stable and unsurprising. diff --git a/ILSpy/Bookmarks/BookmarksPane.axaml.cs b/ILSpy/Bookmarks/BookmarksPane.axaml.cs index 1503a5af73..4e7220f014 100644 --- a/ILSpy/Bookmarks/BookmarksPane.axaml.cs +++ b/ILSpy/Bookmarks/BookmarksPane.axaml.cs @@ -35,7 +35,7 @@ void OnRowDoubleTapped(object? sender, TappedEventArgs e) { if (DataContext is BookmarksPaneModel model && BookmarkGrid.SelectedItem is Bookmark bookmark) { - _ = model.ActivateAsync(bookmark); + model.ActivateAsync(bookmark).HandleExceptions(); e.Handled = true; } } diff --git a/ILSpy/Bookmarks/BookmarksPaneModel.cs b/ILSpy/Bookmarks/BookmarksPaneModel.cs index 53d15c80a4..c226910aee 100644 --- a/ILSpy/Bookmarks/BookmarksPaneModel.cs +++ b/ILSpy/Bookmarks/BookmarksPaneModel.cs @@ -133,7 +133,7 @@ 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]); + ActivateAsync(Bookmarks[next]).HandleExceptions(); } /// diff --git a/ILSpy/Images.cs b/ILSpy/Images.cs index e0bedebb47..b3508dc681 100644 --- a/ILSpy/Images.cs +++ b/ILSpy/Images.cs @@ -86,10 +86,9 @@ 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 they can't use nameof; "Boomark.Disable" is the - // asset's actual -- misspelled -- file name). + // 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("Boomark.Disable"); + 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"); From 4184d64d36b936a2e4b26c6d27845ce2bfc1f13d Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 29 Jun 2026 23:39:45 +0200 Subject: [PATCH 11/13] Stop rescanning references to paint the bookmark gutter every frame Two costs on the gutter's hot path: The margin rebuilt its document-line -> glyph map on every Render, resolving each bookmark by scanning the document's reference segments. A scroll, a hover, or the 600 ms post-navigation pulse repaints many times a second, so this rescanned the references per bookmark per frame. Compute the map once and reuse it, clearing the cache only when the document or the bookmark set changes (the model's DebugInfo/References are set before the text, so the document-change hook sees them current). CanToggleBookmarkAtLine, called for every gutter line the pointer moves over, built a full bookmark including a captured editor view state -- a foldings snapshot, scroll offsets, and a tree-path walk -- only to test it for null. It now does an anchor-only check; the view state is captured only when a bookmark is actually created. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs | 65 ++++++++++++++++++++ ILSpy/Bookmarks/BookmarkMargin.cs | 57 ++++++++++++----- ILSpy/TextView/DecompilerTextView.axaml.cs | 24 ++++++-- 3 files changed, 127 insertions(+), 19 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs index 5215c00431..d73f845bce 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs @@ -30,8 +30,10 @@ 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; @@ -210,4 +212,67 @@ Point GutterPoint(VisualLine line) 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/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs index 68a413462c..038abdd861 100644 --- a/ILSpy/Bookmarks/BookmarkMargin.cs +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -60,6 +60,9 @@ public sealed class BookmarkMargin : AbstractMargin int hoverLine = -1; // A line whose hover preview stays hidden after a removal click, until the pointer leaves it. int suppressedHoverLine = -1; + // Cached document-line -> glyph map and the content version it was built against. + Dictionary? glyphByLine; + (object?, object?) glyphByLineVersion; public BookmarkMargin(TextView.DecompilerTextView owner) { @@ -105,9 +108,14 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e pulseTimer.Stop(); pulseLine = -1; hoverLine = -1; + glyphByLine = null; } - void OnBookmarksChanged(object? sender, EventArgs e) => InvalidateVisual(); + void OnBookmarksChanged(object? sender, EventArgs e) + { + glyphByLine = null; + InvalidateVisual(); + } /// Plays the one-shot bounce on the bookmark glyph at . public void PulseLine(int line) @@ -146,20 +154,7 @@ public override void Render(DrawingContext drawingContext) if (manager == null || textView == null || !textView.VisualLinesValid) return; - // Map the document lines that currently hold a bookmark to their glyph. Bookmarks not in - // this document resolve to no line and are skipped. - var glyphByLine = new Dictionary(); - foreach (var bookmark in manager.Bookmarks) - { - if (owner.GetLineForBookmark(bookmark, updateRenderedLine: false) is { } line) - { - if (bookmark.LineNumber != line) - { - Dispatcher.UIThread.Post(() => bookmark.UpdateRenderedLineNumber(line), DispatcherPriority.Background); - } - glyphByLine[line] = bookmark.Enabled ? Images.Bookmark : Images.BookmarkDisable; - } - } + var glyphByLine = GetGlyphByLine(manager); foreach (var visualLine in textView.VisualLines) { int lineNumber = visualLine.FirstDocumentLine.LineNumber; @@ -194,6 +189,33 @@ public override void Render(DrawingContext drawingContext) } } + // The document line -> glyph map for the current document and bookmark set. Resolving a bookmark + // to its line scans the document's references, so it is computed once and reused across the many + // repaints a scroll, hover or pulse triggers. It is rebuilt when the bookmark set changes (which + // clears the cache) or when the displayed document changes -- detected by its content version, + // since the editor reuses one TextDocument and only swaps its text, so the document reference + // never changes. + Dictionary GetGlyphByLine(BookmarkManager manager) + { + var version = owner.BookmarkContentVersion; + if (glyphByLine != null && version.Equals(glyphByLineVersion)) + return glyphByLine; + glyphByLineVersion = version; + + // Bookmarks not anchored in this document resolve to no line and are skipped. + var map = new Dictionary(); + foreach (var bookmark in manager.Bookmarks) + { + if (owner.GetLineForBookmark(bookmark, updateRenderedLine: false) is { } line) + { + if (bookmark.LineNumber != line) + Dispatcher.UIThread.Post(() => bookmark.UpdateRenderedLineNumber(line), DispatcherPriority.Background); + map[line] = bookmark.Enabled ? Images.Bookmark : Images.BookmarkDisable; + } + } + return glyphByLine = map; + } + // A bitmap copy of the bookmark glyph, shared by all gutters, used only for the hover preview. // SvgImage paints through a custom Skia draw operation that ignores DrawingContext.PushOpacity, // so the SVG can't be faded directly; a rasterized bitmap is composited at the pushed opacity. @@ -300,5 +322,10 @@ void SetHoverLine(int line) // The line currently drawing a hover-preview glyph, or -1 when none. Exposed for tests. internal int HoverPreviewLine => hoverLine; + + // The document lines that currently carry a bookmark glyph, resolved against the live document. + // Exposed for tests because the gutter only paints when rendering is on (off in the headless CI). + internal IReadOnlyDictionary GlyphLinesForTest() + => Manager is { } m ? GetGlyphByLine(m) : new Dictionary(); } } diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 96aa68971a..a0878826f2 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -686,14 +686,22 @@ void OnContextMenuOpening(object? sender, CancelEventArgs e) // Bookmarks live only on the decompiled C# view, not on IL / metadata / resource output. bool ShowsBookmarkableCode => DataContext is DecompilerTabPageModel { SyntaxExtension: ".cs" }; - Bookmarks.Bookmark? CreateBookmarkForLine(int line) + // 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; - var bookmark = Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line, + return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line, fallbackOwner, GetLocationNodeName(model.CurrentNode)); - if (bookmark != null) + } + + 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); @@ -706,7 +714,7 @@ void OnContextMenuOpening(object? sender, CancelEventArgs e) => node is IMemberTreeNode { Member: { } member } ? member.FullName : node?.ToString(); /// Whether can hold a bookmark in the current document. - internal bool CanToggleBookmarkAtLine(int line) => CreateBookmarkForLine(line) != null; + internal bool CanToggleBookmarkAtLine(int line) => CreateAnchorForLine(line) != null; internal bool CanToggleBookmarkAtOffset(int offset) { @@ -855,6 +863,14 @@ void CenterLineInView(TextDocument document, int line) 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 From cf0e67cbb6c7fa00b61ff672e8b3a6e1c79f13b1 Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 30 Jun 2026 00:02:34 +0200 Subject: [PATCH 12/13] Share one MutexProtector between settings and the bookmark list BookmarkManager carried a byte-for-byte copy of ILSpySettings' private MutexProtector, both guarding read-modify-write of a config sidecar in the same settings directory so parallel ILSpy instances fold their edits in turn instead of clobbering each other. Promote it to a single public type in ICSharpCode.ILSpyX.Settings so the locking semantics live in one place. Assisted-by: Claude:claude-opus-4-8:Claude Code --- ICSharpCode.ILSpyX/Settings/ILSpySettings.cs | 30 ---------- ICSharpCode.ILSpyX/Settings/MutexProtector.cs | 55 +++++++++++++++++++ ILSpy/Bookmarks/BookmarkManager.cs | 28 +--------- 3 files changed, 56 insertions(+), 57 deletions(-) create mode 100644 ICSharpCode.ILSpyX/Settings/MutexProtector.cs 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/Bookmarks/BookmarkManager.cs b/ILSpy/Bookmarks/BookmarkManager.cs index aff5d75588..cd7786fbce 100644 --- a/ILSpy/Bookmarks/BookmarkManager.cs +++ b/ILSpy/Bookmarks/BookmarkManager.cs @@ -27,9 +27,9 @@ using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; -using System.Threading; using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpyX.Settings; namespace ICSharpCode.ILSpy.Bookmarks { @@ -361,31 +361,5 @@ sealed record BookmarkRecord( ViewState = ViewState, }; } - - sealed class MutexProtector : IDisposable - { - readonly Mutex mutex; - - public MutexProtector(string name) - { - mutex = new Mutex(true, name, out bool createdNew); - if (createdNew) - return; - - try - { - mutex.WaitOne(); - } - catch (AbandonedMutexException) - { - } - } - - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } } } From 759266ef6fc996a20fe26486dd91100a2ea3fdaf Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Tue, 30 Jun 2026 00:03:27 +0200 Subject: [PATCH 13/13] De-duplicate bookmark match/folding helpers; enabled glyph wins on a shared line - Extract MatchesBookmark for the token + assembly identity that tied a bookmark to a member; GetLineForBookmark and DocumentContainsBookmarkToken each spelled it out (four copies), so a change to the match rule had to be made everywhere. - Add a FoldingSection overload to FoldingsViewState.Restore and route the bookmark folding restore through it, dropping a duplicated checksum + match loop. - When two bookmarks resolve to the same gutter line, draw the enabled glyph so the line never reads as disabled while an active bookmark sits on it. - Drop an out-of-band reference in a test comment. Assisted-by: Claude:claude-opus-4-8:Claude Code --- .../Bookmarks/BookmarkNavigationStepTests.cs | 2 +- ILSpy/Bookmarks/BookmarkMargin.cs | 7 ++- ILSpy/TextView/DecompilerTextView.axaml.cs | 48 +++++++------------ ILSpy/TextView/FoldingsViewState.cs | 36 +++++++++----- 4 files changed, 49 insertions(+), 44 deletions(-) diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs index 0bd4950771..84244e7efc 100644 --- a/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs +++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs @@ -30,7 +30,7 @@ namespace ICSharpCode.ILSpy.Tests.Bookmarks; [TestFixture] public class BookmarkNavigationStepTests { - // Three bookmarks, the middle one disabled (the reported scenario). + // Three bookmarks with the middle one disabled, so next/previous must step over it. static readonly IReadOnlyList ThreeMiddleDisabled = new[] { true, false, true }; [Test] diff --git a/ILSpy/Bookmarks/BookmarkMargin.cs b/ILSpy/Bookmarks/BookmarkMargin.cs index 038abdd861..eae439db67 100644 --- a/ILSpy/Bookmarks/BookmarkMargin.cs +++ b/ILSpy/Bookmarks/BookmarkMargin.cs @@ -210,7 +210,12 @@ Dictionary GetGlyphByLine(BookmarkManager manager) { if (bookmark.LineNumber != line) Dispatcher.UIThread.Post(() => bookmark.UpdateRenderedLineNumber(line), DispatcherPriority.Background); - map[line] = bookmark.Enabled ? Images.Bookmark : Images.BookmarkDisable; + // When two bookmarks resolve to the same line, an enabled one wins so the line never + // reads as disabled while an active bookmark sits on it. + if (bookmark.Enabled) + map[line] = Images.Bookmark; + else if (!map.ContainsKey(line)) + map[line] = Images.BookmarkDisable; } } return glyphByLine = map; diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index a0878826f2..54eb45e70f 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -821,30 +821,9 @@ void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null) } void RestoreBookmarkFoldings(Bookmarks.BookmarkViewState viewState) - => RestoreCurrentFoldings(viewState.ToDecompilerTextViewState().Foldings); - - void RestoreCurrentFoldings(FoldingsViewState.Snapshot? saved) { - if (saved == null || activeFoldingManager is not { } manager) - return; - - var current = FoldingsViewState.Capture(manager.AllFoldings); - if (current.Checksum != saved.Value.Checksum) - return; - - foreach (var folding in manager.AllFoldings) - { - bool wasExpanded = false; - foreach (var (start, end) in saved.Value.Expanded) - { - if (start == folding.StartOffset && end == folding.EndOffset) - { - wasExpanded = true; - break; - } - } - folding.IsFolded = !wasExpanded; - } + 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 @@ -888,7 +867,7 @@ void CenterLineInView(TextDocument document, int line) return null; foreach (var method in debug.Methods) { - if (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName + if (MatchesBookmark(method, bookmark) && method.TryGetLineForOffset(bookmark.ILOffset, out var bodyLine)) { if (updateRenderedLine) @@ -912,9 +891,7 @@ void CenterLineInView(TextDocument document, int line) { foreach (var segment in references) { - if (segment.IsDefinition && segment.Reference is IEntity entity - && (uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token - && entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName) + if (segment.IsDefinition && segment.Reference is IEntity entity && MatchesBookmark(entity, bookmark)) { var line = Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber; if (updateRenderedLine) @@ -926,13 +903,24 @@ void CenterLineInView(TextDocument document, int 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 (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName) + if (MatchesBookmark(method, bookmark)) return true; } } @@ -941,9 +929,7 @@ static bool DocumentContainsBookmarkToken(DecompilerTabPageModel model, Bookmark { foreach (var segment in model.References) { - if (segment.Reference is IEntity entity - && (uint)MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token - && entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName) + if (segment.Reference is IEntity entity && MatchesBookmark(entity, bookmark)) return true; } } 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; } } }