mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
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 Codepull/3839/head
43 changed files with 2678 additions and 33 deletions
@ -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<string>? 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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<LanguageService>().Languages.First(l => l.Name == "C#"); |
||||||
|
|
||||||
|
var asm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("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 "// <assembly name>" comment: neither a statement nor a definition.
|
||||||
|
BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, 1).Should().BeNull(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<LanguageService>().Languages.First(l => l.Name == "C#"); |
||||||
|
|
||||||
|
var asm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<string>? 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"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<bool> 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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<MethodDebugInfo> { 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<MethodDebugInfo> { Method() }); |
||||||
|
info.TryGetBodyAnchor(1, out _, out _).Should().BeFalse(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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<LineHighlightAdorner>().Single().Dismiss(); |
||||||
|
renderers.Should().NotContain(r => r is LineHighlightAdorner, "Dismiss unregisters the adorner"); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Resolves auxiliary configuration files that live as JSON sidecars next to the main
|
||||||
|
/// <c>ILSpy.xml</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConfigurationFiles |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Returns the full path for <paramref name="fileName"/> 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 <see cref="ILSpySettings.SettingsFilePathProvider"/>).
|
||||||
|
/// </summary>
|
||||||
|
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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 912 B |
|
After Width: | Height: | Size: 1008 B |
|
After Width: | Height: | Size: 648 B |
|
After Width: | Height: | Size: 1.6 KiB |
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// How a <see cref="Bookmark"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public enum BookmarkKind |
||||||
|
{ |
||||||
|
/// <summary>A definition line (type, method, field, property, event): module + token.</summary>
|
||||||
|
Token, |
||||||
|
|
||||||
|
/// <summary>A line inside a method body: module + method token + IL offset.</summary>
|
||||||
|
Body |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single user bookmark in the flat bookmark list. The anchor fields are immutable
|
||||||
|
/// (set when the bookmark is created); only <see cref="Name"/> and <see cref="Enabled"/>
|
||||||
|
/// change at runtime, both bound two-way in the bookmarks pane.
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class Bookmark : ObservableObject |
||||||
|
{ |
||||||
|
[ObservableProperty] |
||||||
|
private string name = ""; |
||||||
|
|
||||||
|
[ObservableProperty] |
||||||
|
private bool enabled = true; |
||||||
|
|
||||||
|
/// <summary>Path of the assembly file, used to reload it from disk on navigation.</summary>
|
||||||
|
public required string FileName { get; init; } |
||||||
|
|
||||||
|
/// <summary>Assembly identity (also part of the duplicate/merge key).</summary>
|
||||||
|
public required string AssemblyFullName { get; init; } |
||||||
|
|
||||||
|
/// <summary>Module file name, shown in the pane's "Module" column.</summary>
|
||||||
|
public required string ModuleName { get; init; } |
||||||
|
|
||||||
|
/// <summary>Metadata token of the member (or, for <see cref="BookmarkKind.Body"/>, the enclosing method).</summary>
|
||||||
|
public required uint Token { get; init; } |
||||||
|
|
||||||
|
public required BookmarkKind Kind { get; init; } |
||||||
|
|
||||||
|
/// <summary>IL offset within the method body; meaningful only for <see cref="BookmarkKind.Body"/>.</summary>
|
||||||
|
public int ILOffset { get; init; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public required string MemberName { get; init; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identity used for toggle and merge-import deduplication: same assembly + same location
|
||||||
|
/// (token, plus IL offset for body anchors) means the same bookmark.
|
||||||
|
/// </summary>
|
||||||
|
public string AnchorKey => $"{AssemblyFullName}|{ModuleName}|{Token}|{(Kind == BookmarkKind.Body ? ILOffset : -1)}"; |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Turns a clicked line in the decompiled C# view into a <see cref="Bookmark"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class BookmarkAnchoring |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// Builds an unnamed candidate bookmark for <paramref name="line"/>, or null when the line
|
||||||
|
/// is neither a statement nor a definition (e.g. a blank line or a lone brace).
|
||||||
|
/// </summary>
|
||||||
|
public static Bookmark? CreateForLine(DecompiledDebugInfo? debugInfo, |
||||||
|
TextSegmentCollection<ReferenceSegment>? 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, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="ILInstruction"/>(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.
|
||||||
|
/// </summary>
|
||||||
|
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<ILFunction, Dictionary<int, int>> 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<ILInstruction>()) |
||||||
|
{ |
||||||
|
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<ILFunction>().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, int>(); |
||||||
|
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); |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a <see cref="MethodDebugInfo"/> for each captured function with the output. Call
|
||||||
|
/// once, after the formatting pass has visited the whole syntax tree.
|
||||||
|
/// </summary>
|
||||||
|
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)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
static class BookmarkDialogs |
||||||
|
{ |
||||||
|
public static async Task<bool> ConfirmRemoveMissingAsync() |
||||||
|
=> "yes" == await ShowChoiceAsync(Resources.Bookmarks, Resources.BookmarkAssemblyMissing, |
||||||
|
(Resources._Remove, "yes"), (Resources.Cancel, "no")); |
||||||
|
|
||||||
|
public static async Task<BookmarkImportMode?> 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<string?> 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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>What to do when importing into a non-empty list.</summary>
|
||||||
|
public enum BookmarkImportMode |
||||||
|
{ |
||||||
|
/// <summary>Discard the current list and replace it with the imported one.</summary>
|
||||||
|
Replace, |
||||||
|
|
||||||
|
/// <summary>Keep the current list, adding only imported bookmarks not already present.</summary>
|
||||||
|
Merge |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single source of truth for the flat bookmark list. Holds the live
|
||||||
|
/// <see cref="ObservableCollection{Bookmark}"/>, persists it to <c>ILSpy.Bookmarks.json</c>
|
||||||
|
/// next to <c>ILSpy.xml</c>, and raises <see cref="Changed"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
[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(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>The live list, bound directly by the bookmarks pane.</summary>
|
||||||
|
public ObservableCollection<Bookmark> Bookmarks { get; } = new(); |
||||||
|
|
||||||
|
/// <summary>Raised after any structural change or any bookmark name/enabled edit.</summary>
|
||||||
|
public event EventHandler? Changed; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds <paramref name="bookmark"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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(); |
||||||
|
|
||||||
|
/// <summary>Writes the current list to <paramref name="path"/> in the on-disk JSON format.</summary>
|
||||||
|
public void Export(string path) |
||||||
|
{ |
||||||
|
ArgumentNullException.ThrowIfNull(path); |
||||||
|
WriteTo(path); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Loads bookmarks from <paramref name="path"/>. In <see cref="BookmarkImportMode.Merge"/>
|
||||||
|
/// only entries whose anchor is not already present are added; in
|
||||||
|
/// <see cref="BookmarkImportMode.Replace"/> the current list is discarded first.
|
||||||
|
/// </summary>
|
||||||
|
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<string>(Bookmarks.Select(b => b.AnchorKey)); |
||||||
|
foreach (var b in imported) |
||||||
|
{ |
||||||
|
if (present.Add(b.AnchorKey)) |
||||||
|
Bookmarks.Add(b); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>Persists the current list to the standard sidecar location.</summary>
|
||||||
|
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<string>(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<Bookmark> ReadFrom(string path) |
||||||
|
{ |
||||||
|
if (!System.IO.File.Exists(path)) |
||||||
|
return new List<Bookmark>(); |
||||||
|
try |
||||||
|
{ |
||||||
|
using var stream = System.IO.File.OpenRead(path); |
||||||
|
var file = JsonSerializer.Deserialize<BookmarkFile>(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<Bookmark>(); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
Debug.WriteLine($"[BookmarkManager] Load failed: {ex}"); |
||||||
|
return new List<Bookmark>(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 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<BookmarkRecord> 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, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<BookmarkManager>(); |
||||||
|
if (manager != null) |
||||||
|
manager.Changed += OnBookmarksChanged; |
||||||
|
pulseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) }; |
||||||
|
pulseTimer.Tick += OnPulseTick; |
||||||
|
} |
||||||
|
|
||||||
|
void OnBookmarksChanged(object? sender, EventArgs e) => InvalidateVisual(); |
||||||
|
|
||||||
|
/// <summary>Plays the one-shot bounce on the bookmark glyph at <paramref name="line"/>.</summary>
|
||||||
|
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<int, IImage>(); |
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<AssemblyTreeModel>(); |
||||||
|
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<DockWorkspace>()?.ActiveDecompilerTab is { } tab) |
||||||
|
tab.PendingBookmark = bookmark; |
||||||
|
assemblyTree.SelectNode(node); |
||||||
|
} |
||||||
|
|
||||||
|
async Task OfferRemoveAsync(Bookmark bookmark) |
||||||
|
{ |
||||||
|
if (await BookmarkDialogs.ConfirmRemoveMissingAsync()) |
||||||
|
manager.Remove(bookmark); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,105 @@ |
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui" |
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" |
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" |
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" |
||||||
|
xmlns:bookmarks="using:ICSharpCode.ILSpy.Bookmarks" |
||||||
|
xmlns:res="using:ICSharpCode.ILSpy.Properties" |
||||||
|
xmlns:ilspy="using:ICSharpCode.ILSpy" |
||||||
|
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="200" |
||||||
|
x:Class="ICSharpCode.ILSpy.Bookmarks.BookmarksPane" |
||||||
|
x:DataType="bookmarks:BookmarksPaneModel"> |
||||||
|
<Grid RowDefinitions="Auto,*" Margin="4"> |
||||||
|
<!-- Toolbar: bookmark actions. Each button shows a 16x16 glyph and a tooltip; commands live |
||||||
|
on the pane view model. --> |
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,4" Spacing="2"> |
||||||
|
<StackPanel.Styles> |
||||||
|
<Style Selector="Button"> |
||||||
|
<Setter Property="Background" Value="Transparent" /> |
||||||
|
<Setter Property="BorderThickness" Value="0" /> |
||||||
|
<Setter Property="Padding" Value="4" /> |
||||||
|
</Style> |
||||||
|
<Style Selector="Button > Image"> |
||||||
|
<Setter Property="Width" Value="16" /> |
||||||
|
<Setter Property="Height" Value="16" /> |
||||||
|
</Style> |
||||||
|
</StackPanel.Styles> |
||||||
|
<Button Command="{Binding ToggleCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkToggle}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.Bookmark}" /> |
||||||
|
</Button> |
||||||
|
<Border Width="1" Margin="2,2" Background="{DynamicResource ILSpy.ToolbarSeparatorBrush}" /> |
||||||
|
<Button Command="{Binding PreviousCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkPreviousBookmark}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.BookmarkPrevious}" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding NextCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkNextBookmark}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.BookmarkNext}" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding PreviousInFileCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkPreviousInFile}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.BookmarkPreviousInFile}" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding NextInFileCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkNextInFile}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.BookmarkNextInFile}" /> |
||||||
|
</Button> |
||||||
|
<Border Width="1" Margin="2,2" Background="{DynamicResource ILSpy.ToolbarSeparatorBrush}" /> |
||||||
|
<Button Command="{Binding DisableCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkDisable}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.BookmarkDisable}" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding DeleteCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkDelete}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.BookmarkClear}" /> |
||||||
|
</Button> |
||||||
|
<Border Width="1" Margin="2,2" Background="{DynamicResource ILSpy.ToolbarSeparatorBrush}" /> |
||||||
|
<Button Command="{Binding ExportCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkExport}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.Save}" /> |
||||||
|
</Button> |
||||||
|
<Button Command="{Binding ImportCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkImport}"> |
||||||
|
<Image Source="{x:Static ilspy:Images.Open}" /> |
||||||
|
</Button> |
||||||
|
</StackPanel> |
||||||
|
|
||||||
|
<!-- The flat bookmark list. The Name column is editable; double-click any row navigates. --> |
||||||
|
<!-- CanUserAddRows=False removes the trailing new-item placeholder row: bookmarks are only |
||||||
|
created by toggling in the editor, never typed into the grid. (Bookmark has a |
||||||
|
parameterless ctor, so the grid would otherwise offer an empty add-new row.) --> |
||||||
|
<DataGrid Grid.Row="1" Name="BookmarkGrid" |
||||||
|
ItemsSource="{Binding Bookmarks}" |
||||||
|
SelectedItem="{Binding SelectedBookmark, Mode=TwoWay}" |
||||||
|
AutoGenerateColumns="False" |
||||||
|
CanUserAddRows="False" |
||||||
|
CanUserResizeColumns="True" |
||||||
|
HeadersVisibility="Column" |
||||||
|
SelectionMode="Single"> |
||||||
|
<DataGrid.Columns> |
||||||
|
<!-- A live CheckBox in a template column toggles on a single click; a |
||||||
|
DataGridCheckBoxColumn would require first entering cell-edit mode. --> |
||||||
|
<DataGridTemplateColumn Width="Auto"> |
||||||
|
<DataGridTemplateColumn.CellTemplate> |
||||||
|
<DataTemplate DataType="bookmarks:Bookmark"> |
||||||
|
<CheckBox IsChecked="{Binding Enabled, Mode=TwoWay}" |
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center" Margin="6,0" /> |
||||||
|
</DataTemplate> |
||||||
|
</DataGridTemplateColumn.CellTemplate> |
||||||
|
</DataGridTemplateColumn> |
||||||
|
<DataGridTemplateColumn Header="{x:Static res:Resources.Name}" Width="2*"> |
||||||
|
<DataGridTemplateColumn.CellTemplate> |
||||||
|
<DataTemplate DataType="bookmarks:Bookmark"> |
||||||
|
<Grid ColumnDefinitions="Auto,4,*" Margin="2,1"> |
||||||
|
<Image Grid.Column="0" Source="{x:Static ilspy:Images.Bookmark}" |
||||||
|
Width="16" Height="16" VerticalAlignment="Center" /> |
||||||
|
<TextBlock Grid.Column="2" Text="{Binding Name}" VerticalAlignment="Center" |
||||||
|
TextTrimming="CharacterEllipsis" /> |
||||||
|
</Grid> |
||||||
|
</DataTemplate> |
||||||
|
</DataGridTemplateColumn.CellTemplate> |
||||||
|
<DataGridTemplateColumn.CellEditingTemplate> |
||||||
|
<DataTemplate DataType="bookmarks:Bookmark"> |
||||||
|
<TextBox Text="{Binding Name, Mode=TwoWay}" AcceptsReturn="False" /> |
||||||
|
</DataTemplate> |
||||||
|
</DataGridTemplateColumn.CellEditingTemplate> |
||||||
|
</DataGridTemplateColumn> |
||||||
|
<DataGridTextColumn Header="{x:Static res:Resources.Location}" Width="3*" |
||||||
|
Binding="{Binding MemberName}" IsReadOnly="True" /> |
||||||
|
<DataGridTextColumn Header="{x:Static res:Resources.BookmarkModule}" Width="2*" |
||||||
|
Binding="{Binding ModuleName}" IsReadOnly="True" /> |
||||||
|
</DataGrid.Columns> |
||||||
|
</DataGrid> |
||||||
|
</Grid> |
||||||
|
</UserControl> |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>The live bookmark list, bound by the grid.</summary>
|
||||||
|
public ObservableCollection<Bookmark> Bookmarks => manager.Bookmarks; |
||||||
|
|
||||||
|
/// <summary>Double-click / Enter on a row: select it and jump to its location.</summary>
|
||||||
|
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<DockWorkspace>()?.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]); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The index of the next enabled item <paramref name="delta"/> steps from
|
||||||
|
/// <paramref name="selectedIndex"/> (skipping disabled, wrapping around), or null when no
|
||||||
|
/// enabled item exists. A negative <paramref name="selectedIndex"/> (nothing selected) starts
|
||||||
|
/// just outside the list so the first step lands on the first / last candidate.
|
||||||
|
/// </summary>
|
||||||
|
internal static int? NextEnabledIndex(IReadOnlyList<bool> 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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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; } |
||||||
|
|
||||||
|
/// <summary>The IL offset of the statement that starts on <paramref name="line"/>, if any.</summary>
|
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The text line for <paramref name="ilOffset"/>: 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).
|
||||||
|
/// </summary>
|
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All per-method <see cref="MethodDebugInfo"/> maps for one decompiled document, the bridge
|
||||||
|
/// between a clicked line and a token+IL-offset body anchor (and back).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class DecompiledDebugInfo |
||||||
|
{ |
||||||
|
public static readonly DecompiledDebugInfo Empty = new(new List<MethodDebugInfo>()); |
||||||
|
|
||||||
|
readonly IReadOnlyList<MethodDebugInfo> methods; |
||||||
|
readonly Dictionary<uint, MethodDebugInfo> byToken; |
||||||
|
|
||||||
|
public DecompiledDebugInfo(IReadOnlyList<MethodDebugInfo> methods) |
||||||
|
{ |
||||||
|
this.methods = methods; |
||||||
|
byToken = new Dictionary<uint, MethodDebugInfo>(); |
||||||
|
foreach (var m in methods) |
||||||
|
byToken[m.Token] = m; |
||||||
|
} |
||||||
|
|
||||||
|
public IReadOnlyList<MethodDebugInfo> Methods => methods; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a clicked <paramref name="line"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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; |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>The text line for a stored body anchor, used to place the gutter icon and to scroll on navigation.</summary>
|
||||||
|
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; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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 |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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)); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>Registers a one-shot line highlight for <paramref name="line"/> on <paramref name="textArea"/>.</summary>
|
||||||
|
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(); |
||||||
|
} |
||||||
|
|
||||||
|
/// <summary>Ends the highlight immediately: stops the timers and unregisters from the text view.</summary>
|
||||||
|
public void Dismiss() |
||||||
|
{ |
||||||
|
lifetimeTimer.Stop(); |
||||||
|
frameTimer.Stop(); |
||||||
|
textArea.TextView.BackgroundRenderers.Remove(this); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue