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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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