From 39d9020efdf19f03581ba8bb86baa1b60282a70d Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Wed, 17 Jun 2026 15:56:26 +0200 Subject: [PATCH] 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 000000000..f421ec107 --- /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 000000000..a2581e404 --- /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 000000000..8a3fccedd --- /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 000000000..b96e2f0f7 --- /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 000000000..0bd495077 --- /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 000000000..7a43dce16 --- /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 a8ce81906..c97c0e14d 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 class DockWorkspaceTests 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 000000000..133775ab5 --- /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 000000000..120ceae63 --- /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 000000000..80ba6d07f --- /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 000000000..4a06c73f6 --- /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 000000000..5d6aca6a5 --- /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 000000000..2bb717059 --- /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 000000000..dbdf8f1b3 --- /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 000000000..eb07fb1d8 --- /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 000000000..43594b8ec --- /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 000000000..f0f82f00e --- /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 000000000..2c507fd3a --- /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 000000000..35f8be8ba --- /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 000000000..752548172 --- /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 000000000..d2c4c5956 --- /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 000000000..2bf425678 --- /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 000000000..b6bb59f32 --- /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 000000000..af51fd68a --- /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 000000000..3a640ad5a --- /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 000000000..6946f7fb0 --- /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 000000000..02e9fe8b4 --- /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 000000000..49c649ae8 --- /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 000000000..1503a5af7 --- /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 000000000..a8c7d4c7f --- /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 000000000..9f045c495 --- /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 000000000..4cd184aa7 --- /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 30cb54db6..5d4455332 100644 --- a/ILSpy/Commands/FilePickers.cs +++ b/ILSpy/Commands/FilePickers.cs @@ -64,6 +64,25 @@ namespace ICSharpCode.ILSpy.Commands 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 848a95dd1..fd75c10cd 100644 --- a/ILSpy/Docking/DockWorkspace.cs +++ b/ILSpy/Docking/DockWorkspace.cs @@ -131,22 +131,10 @@ namespace ICSharpCode.ILSpy.Docking 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 7abd58597..66082e5bb 100644 --- a/ILSpy/Images.cs +++ b/ILSpy/Images.cs @@ -86,6 +86,16 @@ namespace ICSharpCode.ILSpy 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 b777e3182..73a2fcc0a 100644 --- a/ILSpy/Languages/CSharpLanguage.cs +++ b/ILSpy/Languages/CSharpLanguage.cs @@ -345,11 +345,11 @@ namespace ICSharpCode.ILSpy.Languages { 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 @@ namespace ICSharpCode.ILSpy.Languages { 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 @@ namespace ICSharpCode.ILSpy.Languages 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 @@ namespace ICSharpCode.ILSpy.Languages 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 @@ namespace ICSharpCode.ILSpy.Languages { 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 @@ namespace ICSharpCode.ILSpy.Languages { 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 @@ namespace ICSharpCode.ILSpy.Languages 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 @@ namespace ICSharpCode.ILSpy.Languages } } - 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 11f9cb5cf..ea32f0f0e 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 @@ namespace ICSharpCode.ILSpy.Properties { 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 80ff70b62..a639f26cb 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 64611b91e..9c04fcf48 100644 --- a/ILSpy/TextView/AvaloniaEditTextOutput.cs +++ b/ILSpy/TextView/AvaloniaEditTextOutput.cs @@ -60,6 +60,10 @@ namespace ICSharpCode.ILSpy.TextView 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 @@ namespace ICSharpCode.ILSpy.TextView /// 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 bfd487e5e..1e1dbd001 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -183,6 +183,14 @@ namespace ICSharpCode.ILSpy.TextView [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 @@ namespace ICSharpCode.ILSpy.TextView /// 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 @@ namespace ICSharpCode.ILSpy.TextView Foldings = null; References = null; DefinitionLookup = null; + DebugInfo = null; UIElements = null; Text = string.Empty; IsDecompiling = false; @@ -711,6 +734,7 @@ namespace ICSharpCode.ILSpy.TextView 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 85a4a8dd5..d7f2cf300 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -93,6 +93,8 @@ namespace ICSharpCode.ILSpy.TextView // 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 @@ namespace ICSharpCode.ILSpy.TextView // 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 @@ namespace ICSharpCode.ILSpy.TextView 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 @@ namespace ICSharpCode.ILSpy.TextView } } + #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 @@ namespace ICSharpCode.ILSpy.TextView { previous.PropertyChanged -= OnModelPropertyChanged; previous.CaptureViewState = null; + previous.ToggleBookmarkAtCaret = null; + previous.NavigateBookmarkInFile = null; } boundModel = DataContext as DecompilerTabPageModel; @@ -668,6 +829,9 @@ namespace ICSharpCode.ILSpy.TextView // 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 @@ namespace ICSharpCode.ILSpy.TextView { 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 @@ namespace ICSharpCode.ILSpy.TextView 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 000000000..6834889ef --- /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 91c0cf2ea..766383140 100644 --- a/ILSpy/ViewLocator.cs +++ b/ILSpy/ViewLocator.cs @@ -27,6 +27,7 @@ using Dock.Model.Core; 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 @@ namespace ICSharpCode.ILSpy 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() },