Browse Source

Add a bookmarks feature for the decompiled C# view

There was no way to mark and return to interesting spots in decompiled
code. This adds a flat (no folders, no labels) bookmark list: toggle on a
line from the context menu, the gutter, or Ctrl+B; see an icon in a new
left-margin gutter that honours a disabled state; and manage the list in a
dockable pane that auto-registers in the Window menu.

Bookmarks anchor by metadata token, never by a raw line number, so they
survive re-decompilation and decompiler-setting changes that reflow the C#
text: a definition line anchors to its token, while a line inside a method
body anchors to the method token plus an IL offset. Recovering an IL offset
needs the decompiler's sequence points, which the normal C# output did not
carry, so they are captured once at the WriteCode chokepoint and stored as a
per-document line/offset map. The map is also what places gutter icons and
scrolls navigation to the exact line.

The list persists to an ILSpy.Bookmarks.json sidecar next to ILSpy.xml; the
path logic is extracted into AppEnv/ConfigurationFiles so the dock layout
sidecar shares it. Navigating to a bookmark loads its assembly from disk if
it dropped out of the list (and only then offers to remove a bookmark whose
file is gone), then centres the line and plays a brief line flash plus a
gutter-icon pulse. Disabled bookmarks stay visible but are skipped by the
next/previous actions.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3839/head
Christoph Wille 3 weeks ago committed by Siegfried Pammer
parent
commit
39d9020efd
  1. 62
      ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs
  2. 76
      ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs
  3. 71
      ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs
  4. 185
      ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs
  5. 75
      ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs
  6. 87
      ILSpy.Tests/Bookmarks/MethodDebugInfoTests.cs
  7. 10
      ILSpy.Tests/Docking/DockWorkspaceTests.cs
  8. 51
      ILSpy.Tests/Editor/LineHighlightAdornerTests.cs
  9. 47
      ILSpy/AppEnv/ConfigurationFiles.cs
  10. 1
      ILSpy/Assets/Icons/Bookmark.Clear.svg
  11. 1
      ILSpy/Assets/Icons/Bookmark.GroupDisable.svg
  12. 1
      ILSpy/Assets/Icons/Bookmark.Next.File.svg
  13. 1
      ILSpy/Assets/Icons/Bookmark.Next.Folder.svg
  14. 1
      ILSpy/Assets/Icons/Bookmark.Next.svg
  15. 1
      ILSpy/Assets/Icons/Bookmark.Previous.File.svg
  16. 42
      ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg
  17. 1
      ILSpy/Assets/Icons/Bookmark.Previous.svg
  18. 1
      ILSpy/Assets/Icons/Bookmark.Window.svg
  19. 1
      ILSpy/Assets/Icons/Bookmark.svg
  20. 1
      ILSpy/Assets/Icons/Boomark.Disable.svg
  21. 80
      ILSpy/Bookmarks/Bookmark.cs
  22. 85
      ILSpy/Bookmarks/BookmarkAnchoring.cs
  23. 103
      ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs
  24. 94
      ILSpy/Bookmarks/BookmarkDialogs.cs
  25. 267
      ILSpy/Bookmarks/BookmarkManager.cs
  26. 147
      ILSpy/Bookmarks/BookmarkMargin.cs
  27. 112
      ILSpy/Bookmarks/BookmarkNavigator.cs
  28. 105
      ILSpy/Bookmarks/BookmarksPane.axaml
  29. 43
      ILSpy/Bookmarks/BookmarksPane.axaml.cs
  30. 162
      ILSpy/Bookmarks/BookmarksPaneModel.cs
  31. 148
      ILSpy/Bookmarks/MethodDebugInfo.cs
  32. 40
      ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs
  33. 19
      ILSpy/Commands/FilePickers.cs
  34. 18
      ILSpy/Docking/DockWorkspace.cs
  35. 10
      ILSpy/Images.cs
  36. 33
      ILSpy/Languages/CSharpLanguage.cs
  37. 157
      ILSpy/Properties/Resources.Designer.cs
  38. 51
      ILSpy/Properties/Resources.resx
  39. 16
      ILSpy/TextView/AvaloniaEditTextOutput.cs
  40. 24
      ILSpy/TextView/DecompilerTabPageModel.cs
  41. 173
      ILSpy/TextView/DecompilerTextView.axaml.cs
  42. 106
      ILSpy/TextView/LineHighlightAdorner.cs
  43. 2
      ILSpy/ViewLocator.cs

62
ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs

@ -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");
}
}

76
ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs

@ -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();
}
}

71
ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs

@ -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);
}
}

185
ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs

@ -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");
}
}

75
ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs

@ -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();
}
}

87
ILSpy.Tests/Bookmarks/MethodDebugInfoTests.cs

@ -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();
}
}

10
ILSpy.Tests/Docking/DockWorkspaceTests.cs

@ -35,7 +35,7 @@ namespace ICSharpCode.ILSpy.Tests.Docking; @@ -35,7 +35,7 @@ namespace ICSharpCode.ILSpy.Tests.Docking;
public class DockWorkspaceTests
{
[AvaloniaTest]
public void DockWorkspace_resolves_and_exposes_root_layout_with_three_tool_panes()
public void DockWorkspace_resolves_and_exposes_root_layout_with_tool_panes()
{
var workspace = AppComposition.Current.GetExport<DockWorkspace>();
workspace.Should().NotBeNull("DockWorkspace is [Export][Shared] in ICSharpCode.ILSpy.Docking.");
@ -43,11 +43,11 @@ public class DockWorkspaceTests @@ -43,11 +43,11 @@ public class DockWorkspaceTests
workspace.Layout.Should().NotBeNull("ILSpyDockFactory.CreateLayout() wires the root dock in the ctor.");
workspace.Factory.Should().NotBeNull();
#if DEBUG
workspace.ToolPaneMenuItems.Should().HaveCount(4,
"AssemblyTree, Search, Analyzers, and the Debug Steps pane (Debug-only) are wired at this point.");
workspace.ToolPaneMenuItems.Should().HaveCount(5,
"AssemblyTree, Search, Analyzers, Bookmarks, and the Debug Steps pane (Debug-only) are wired at this point.");
#else
workspace.ToolPaneMenuItems.Should().HaveCount(3,
"AssemblyTree, Search, and Analyzers are the three tool panes wired at this point.");
workspace.ToolPaneMenuItems.Should().HaveCount(4,
"AssemblyTree, Search, Analyzers, and Bookmarks are the tool panes wired at this point.");
#endif
}
}

51
ILSpy.Tests/Editor/LineHighlightAdornerTests.cs

@ -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");
}
}

47
ILSpy/AppEnv/ConfigurationFiles.cs

@ -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);
}
}
}

1
ILSpy/Assets/Icons/Bookmark.Clear.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.icon-canvas-transparent{opacity:0;fill:#f6f6f6}.icon-vs-out{fill:#f6f6f6}.icon-vs-bg{fill:#424242}.icon-vs-action-red{fill:#a1260d}</style><path class="icon-canvas-transparent" d="M16 16H0V0h16v16z" id="canvas"/><path class="icon-vs-out" d="M14 2v13.868l-4-2.666-4 2.666V8.414l-.061.061-1.97-1.97L2 8.475l-2-2v-.95L1.495 4.03 0 2.535v-.949L1.586 0h.828L3.97 1.556 5.525 0h.828l2 2H14z" id="outline"/><path class="icon-vs-bg" d="M13 3v11l-3-2-3 2V7.414L8.414 6 7 4.586V3.475L7.475 3H13z" id="iconBg"/><path class="icon-vs-action-red" d="M5.03 4.03L7 6 5.939 7.061l-1.97-1.97L2 7.061.939 6l1.97-1.97-1.97-1.97L2 1l1.97 1.97L5.939 1 7 2.06 5.03 4.03z" id="colorAction"/></svg>

After

Width:  |  Height:  |  Size: 741 B

1
ILSpy/Assets/Icons/Bookmark.GroupDisable.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><style type="text/css">.icon-canvas-transparent{opacity:0;fill:#F6F6F6;} .icon-vs-out{fill:#F6F6F6;} .icon-vs-bg{fill:#424242;} .icon-vs-fg{fill:#F0EFF1;}</style><path class="icon-canvas-transparent" d="M16 16h-16v-16h16v16z" id="canvas"/><path class="icon-vs-out" d="M13 12.775l-2-1.199v3.205l-3.5-2.1-3.501 2.131v-11.812h2.001v-2h7v11.775z" id="outline"/><path class="icon-vs-bg" d="M12 11.01l-1-.6v-7.41h-4v-.99h5v9zm-2-6.994v9l-2.5-1.5-2.5 1.5v-9.016l5 .016zm-3.358 6.848l-.642-.641v1.027m3-1.027l-3-3v1.586l1.603 1.603.412.247.985.591v-1.027zm0-3l-2.207-2.207h-.793v.793l3 3v-1.586zm0-2.207h-.793l.793.793v-.793z" id="iconBg"/><path class="icon-vs-fg" d="M6 7.223l3 3v1.027l-1.397-.839-1.603-1.602v-1.586zm.642 3.641l-.642-.641v1.027l.642-.386zm1.565-5.848l.793.793v-.793h-.793zm-2.207 0v.793l3 3v-1.586l-2.207-2.207h-.793z" id="iconFg"/></svg>

After

Width:  |  Height:  |  Size: 912 B

1
ILSpy/Assets/Icons/Bookmark.Next.File.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.st0{fill:#f6f6f6}.st1{fill:#424242}.st2{fill:#00539c}.st3{fill:#f0eff1}</style><path class="st0" d="M12.061 0H.586l2 2H0v4h2.586l-2 2H2v8h1.414L5 14.414 6.586 16H8v-2h6c1.299 0 2-1.031 2-2V3.556L12.061 0z" id="outline"/><g id="icon_x5F_bg"><path class="st1" d="M3 9h4v6l-2-2-2 2zM11.641 1H6.414l1 1H11v3h3v7H8v1h5.965c1 0 1.035-1 1.035-1V4l-3.359-3z"/></g><path class="st2" d="M5 1H3l2 2H1v2h4L3 7h2l3-3z" id="color_x5F_action"/><path class="st3" d="M11 5V2H7.414l2 2L6 7.414V8h2v4h6V5z" id="not_x5F_fg"/></svg>

After

Width:  |  Height:  |  Size: 579 B

1
ILSpy/Assets/Icons/Bookmark.Next.Folder.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.icon-canvas-transparent{opacity:0;fill:#f6f6f6}.icon-vs-out{fill:#f6f6f6}.icon-folder{fill:#dcb67a}.icon-vs-fg{fill:#f0eff1}.icon-vs-action-blue{fill:#00539c}</style><path class="icon-canvas-transparent" d="M0 0h16v16H0V0z" id="canvas"/><path class="icon-vs-out" d="M16 6.5v8c0 .827-.673 1.5-1.5 1.5H2.504c-.827 0-1.5-.673-1.5-1.5V5H.012l-.037-3.015 3.5.009-.488-.46L4.518 0h1.348l3.001 3h1.747l1 2H14.5c.827 0 1.5.673 1.5 1.5z" id="outline"/><path class="icon-folder" d="M15 8H8l2.5 2.5L8 13h7v1.5a.5.5 0 0 1-.5.5H2.504a.5.5 0 0 1-.5-.5V5h1v1h.167l.986 1 1.062 1.077L7.119 6h2.759l-.5-1H8.034l.915-1h1.047l1 2H14.5a.5.5 0 0 1 .5.5V8z" id="iconBg"/><path class="icon-vs-fg" d="M3.82 5l-.803.844.154.156h-.167V5h.816zm5.558 0H8.034l-.915 1h2.759l-.5-1zM8 8l2.5 2.5L8 13h7V8H8z" id="iconFg"/><g id="colorAction"><path class="icon-vs-action-blue" d="M4.422 1.512L6 3 .988 2.987 1 4h5.153L4.409 5.831l.783.794 2.812-3.074L5.192.74z"/></g></svg>

After

Width:  |  Height:  |  Size: 1008 B

1
ILSpy/Assets/Icons/Bookmark.Next.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.st0{opacity:0}.st0,.st1{fill:#f6f6f6}.st2{fill:#424242}.st3{fill:#00539c}</style><g id="outline"><path class="st0" d="M0 0h16v16H0z"/><path class="st1" d="M15 1v14l-4-2-4 2V8H1l2-2H1V2h2L1 0h6l1 1h7z"/></g><path class="st2" d="M9 2l2 2-3 3v6l3-2 3 2V2z" id="icon_x5F_bg"/><path class="st3" d="M6 3H2v2h4L4 7h2l3-3-3-3H4z" id="color_x5F_importance"/></svg>

After

Width:  |  Height:  |  Size: 423 B

1
ILSpy/Assets/Icons/Bookmark.Previous.File.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.st0{opacity:0}.st0,.st1{fill:#f6f6f6}.st2{fill:#424242}.st3{fill:#00539c}.st4{fill:#f0eff1}</style><g id="outline"><path class="st0" d="M0 0h16v16H0z"/><path class="st1" d="M11.061 0H3.586L0 3.586v.828l3 3V8H2v8h1.414L5 14.414 6.586 16H8v-2h5c1.299 0 2-1.029 2-2V3.556L11.061 0z"/></g><g id="icon_x5F_bg"><path class="st2" d="M3 15l2-2 2 2V9H3v6zM10.641 1H7.414l-1 1H10v3h3v7H8v1h4.965c1 0 1.035-1 1.035-1V4l-3.359-3z"/></g><path class="st3" d="M4 3l2-2H4L1 4l3 3h2L4 5h4V3z" id="color_x5F_importance"/><path class="st4" d="M10 5V2H9v4H6.414l2 2H8v4h5V5z" id="icon_x5F_fg"/></svg>

After

Width:  |  Height:  |  Size: 648 B

42
ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 21.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 16 16" style="enable-background:new 0 0 16 16;" xml:space="preserve">
<style type="text/css">
.icon_x002D_canvas_x002D_transparent{opacity:0;fill:#F6F6F6;}
.icon_x002D_vs_x002D_out{fill:#F6F6F6;}
.icon_x002D_folder{fill:#DCB67A;}
.icon_x002D_vs_x002D_action_x002D_blue{fill:#00539C;}
.icon_x002D_vs_x002D_fg{fill:#F0EFF1;}
</style>
<g id="canvas">
<path id="XMLID_113_" class="icon_x002D_canvas_x002D_transparent" d="M0,0h16v16H0V0z"/>
</g>
<g id="outline">
<path class="icon_x002D_vs_x002D_out" d="M14.5,5h-2.886l-1-2H9.878V2.057H6.552l0.432-0.432L5.361,0H4.012L0.33,3.682L1.017,4.37
C1.013,4.413,1.004,4.455,1.004,4.5v10c0,0.827,0.673,1.5,1.5,1.5H14.5c0.827,0,1.5-0.673,1.5-1.5v-8C16,5.673,15.327,5,14.5,5z"/>
</g>
<g id="iconBg">
<path class="icon_x002D_folder" d="M15,6.5C15,6.224,14.777,6,14.5,6h-3.496h-0.008l-1-2H9.878v1.307H9.532L9.878,6H6.723
L4.687,8.04L2.004,5.357V14.5c0,0.276,0.225,0.5,0.5,0.5H14.5c0.277,0,0.5-0.224,0.5-0.5V13H8l2.5-2.5L8,8h7V6.5z"/>
</g>
<g id="colorAction">
<polygon class="icon_x002D_vs_x002D_action_x002D_blue" points="5.57,1.512 3.992,3 9.004,2.987 8.992,4 3.839,4 5.583,5.831
4.8,6.625 1.988,3.551 4.8,0.74 "/>
</g>
<g id="iconFg">
<polygon class="icon_x002D_vs_x002D_fg" points="8,8 10.5,10.5 8,13 15,13 15,8 "/>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

1
ILSpy/Assets/Icons/Bookmark.Previous.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.st0{opacity:0}.st0,.st1{fill:#f6f6f6}.st2{fill:#424242}.st3{fill:#00539c}</style><g id="outline"><path class="st0" d="M0 0h16v16H0z"/><path class="st1" d="M15 1v14l-4-2-4 2V8H5L1 4l4-4h6l-1 1h5z"/></g><path class="st2" d="M11 2v4H9l2 2H8v5l3-2 3 2V2z" id="icon_x5F_bg"/><path class="st3" d="M8 1H6L3 4l3 3h2L6 5h4V3H6z" id="color_x5F_importance"/></svg>

After

Width:  |  Height:  |  Size: 421 B

1
ILSpy/Assets/Icons/Bookmark.Window.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><style>.icon-canvas-transparent{opacity:0;fill:#f6f6f6}.icon-vs-out{fill:#f6f6f6}.icon-vs-bg{fill:#424242}.icon-vs-fg{fill:#f0eff1}.icon-vs-action-orange{fill:#c27d1a}</style><g id="canvas"><path id="XMLID_1_" class="icon-canvas-transparent" d="M16 16H0V0h16v16z"/></g><path class="icon-vs-out" d="M15 4v10H3v-3.34L1 12V1h6v3h8z" id="outline"/><g id="iconBg"><path class="icon-vs-bg" d="M14 5v8H4v-2.99l1 .664V12h8V7H7V5h7z"/><path class="icon-vs-action-orange" d="M2 10l2-2 2 2V2H2z"/></g><g id="iconFg"><path class="icon-vs-fg" d="M5 10.674L7 12H5v-1.326zM13 12V7H7v5h6z"/></g></svg>

After

Width:  |  Height:  |  Size: 645 B

1
ILSpy/Assets/Icons/Bookmark.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><style type="text/css">.icon-canvas-transparent{opacity:0;fill:#F6F6F6;} .icon-vs-out{fill:#F6F6F6;} .icon-vs-bg{fill:#424242;}</style><path class="icon-canvas-transparent" d="M16 16h-16v-16h16v16z" id="canvas"/><path class="icon-vs-out" d="M8.501 10.498l-4.501 4.501v-13.999h9v14l-4.499-4.502z" id="outline"/><path class="icon-vs-bg" d="M5 12.585l3.501-3.501 3.499 3.501v-10.585h-7v10.585z" id="iconBg"/></svg>

After

Width:  |  Height:  |  Size: 474 B

1
ILSpy/Assets/Icons/Boomark.Disable.svg

@ -0,0 +1 @@ @@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"><style type="text/css">.icon-canvas-transparent{opacity:0;fill:#F6F6F6;} .icon-vs-out{fill:#F6F6F6;} .icon-vs-bg{fill:#424242;} .icon-vs-fg{fill:#F0EFF1;}</style><path class="icon-canvas-transparent" d="M16 16h-16v-16h16v16z" id="canvas"/><path class="icon-vs-out" d="M8 12.5l-4 2.5v-14h8v14l-4-2.5z" id="outline"/><path class="icon-vs-bg" d="M5 2v11l3-2 3 2v-11h-6zm1 9v-.793l.479.479m1.521-.998l-.667.438-1.333-1.333v-1.586l3.398 3.398m.602-.812l-4-4v-1.586l4 4v1.586zm0-3l-3.793-3.793h1.586l2.207 2.207v1.586zm0-3l-.793-.793h.793v.793z" id="iconBg"/><path class="icon-vs-fg" d="M6 11v-.793l.479.479-.479.314zm3.398-.394l-3.398-3.399v1.586l1.333 1.333.667-.438 1.398.918zm.602-2.399l-4-4v1.586l4 4v-1.586zm0-3l-2.207-2.207h-1.586l3.793 3.793v-1.586zm0-2.207h-.793l.793.793v-.793z" id="iconFg"/></svg>

After

Width:  |  Height:  |  Size: 865 B

80
ILSpy/Bookmarks/Bookmark.cs

@ -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)}";
}
}

85
ILSpy/Bookmarks/BookmarkAnchoring.cs

@ -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,
};
}
}
}

103
ILSpy/Bookmarks/BookmarkDebugInfoCollector.cs

@ -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));
}
}
}
}

94
ILSpy/Bookmarks/BookmarkDialogs.cs

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

267
ILSpy/Bookmarks/BookmarkManager.cs

@ -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,
};
}
}
}

147
ILSpy/Bookmarks/BookmarkMargin.cs

@ -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 &lt;-&gt; 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;
}
}
}

112
ILSpy/Bookmarks/BookmarkNavigator.cs

@ -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);
}
}
}

105
ILSpy/Bookmarks/BookmarksPane.axaml

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

43
ILSpy/Bookmarks/BookmarksPane.axaml.cs

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

162
ILSpy/Bookmarks/BookmarksPaneModel.cs

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

148
ILSpy/Bookmarks/MethodDebugInfo.cs

@ -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 &lt;-&gt; 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;
}
}
}

40
ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs

@ -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 -&gt; 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();
}
}

19
ILSpy/Commands/FilePickers.cs

@ -64,6 +64,25 @@ namespace ICSharpCode.ILSpy.Commands @@ -64,6 +64,25 @@ namespace ICSharpCode.ILSpy.Commands
return file?.TryGetLocalPath();
}
/// <summary>
/// Shows an open-file picker for a single file. <paramref name="filter"/> uses the same
/// WPF-style <c>display|patterns</c> syntax as <see cref="SaveAsync"/>. Returns the selected
/// absolute path, or <c>null</c> if the user cancelled.
/// </summary>
public static async Task<string?> OpenAsync(string filter, string? title = null)
{
var owner = UiContext.MainWindow;
if (owner == null)
return null;
var files = await owner.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions {
Title = title,
AllowMultiple = false,
FileTypeFilter = ParseFilter(filter),
});
return files.Count == 0 ? null : files[0].TryGetLocalPath();
}
/// <summary>
/// Shows a folder-picker dialog. <paramref name="title"/> appears in the dialog
/// chrome. Returns the selected folder's absolute path, or <c>null</c> if the user

18
ILSpy/Docking/DockWorkspace.cs

@ -131,22 +131,10 @@ namespace ICSharpCode.ILSpy.Docking @@ -131,22 +131,10 @@ namespace ICSharpCode.ILSpy.Docking
public void SaveLayout() => ILSpyDockFactory.SaveLayout(GetLayoutFilePath(), Layout);
/// <summary>
/// Resolves <c>ILSpy.Layout.json</c> as a sidecar in the same directory the
/// XML <c>ILSpy.xml</c> settings file lives in — local-to-binary on portable
/// installs, %APPDATA%/ICSharpCode/ otherwise. Keeping it next to the XML
/// makes "delete settings to reset" still work as a single-folder action.
/// WPF stays XML; this is Avalonia-side only.
/// Resolves <c>ILSpy.Layout.json</c> as a sidecar next to the XML <c>ILSpy.xml</c>
/// settings file. WPF stays XML; this is Avalonia-side only.
/// </summary>
static string GetLayoutFilePath()
{
var xmlPath = ICSharpCode.ILSpyX.Settings.ILSpySettings.SettingsFilePathProvider?.Invoke();
if (string.IsNullOrEmpty(xmlPath))
return "ILSpy.Layout.json";
var dir = System.IO.Path.GetDirectoryName(xmlPath);
return string.IsNullOrEmpty(dir)
? "ILSpy.Layout.json"
: System.IO.Path.Combine(dir, "ILSpy.Layout.json");
}
static string GetLayoutFilePath() => AppEnv.ConfigurationFiles.GetPath("ILSpy.Layout.json");
public IReadOnlyList<ToolPaneMenuItem> ToolPaneMenuItems { get; }

10
ILSpy/Images.cs

@ -86,6 +86,16 @@ namespace ICSharpCode.ILSpy @@ -86,6 +86,16 @@ namespace ICSharpCode.ILSpy
public static readonly IImage ShowPrivateInternal = LoadSvg(nameof(ShowPrivateInternal));
public static readonly IImage ShowAll = LoadSvg(nameof(ShowAll));
// Bookmarks (file names carry dots, so they can't use nameof; "Boomark.Disable" is the
// asset's actual — misspelled — file name).
public static readonly IImage Bookmark = LoadSvg(nameof(Bookmark));
public static readonly IImage BookmarkDisable = LoadSvg("Boomark.Disable");
public static readonly IImage BookmarkNext = LoadSvg("Bookmark.Next");
public static readonly IImage BookmarkPrevious = LoadSvg("Bookmark.Previous");
public static readonly IImage BookmarkNextInFile = LoadSvg("Bookmark.Next.File");
public static readonly IImage BookmarkPreviousInFile = LoadSvg("Bookmark.Previous.File");
public static readonly IImage BookmarkClear = LoadSvg("Bookmark.Clear");
// Type-relation tree nodes (Base Types / Derived Types).
public static readonly IImage SuperTypes = LoadSvg(nameof(SuperTypes));
public static readonly IImage SubTypes = LoadSvg(nameof(SubTypes));

33
ILSpy/Languages/CSharpLanguage.cs

@ -345,11 +345,11 @@ namespace ICSharpCode.ILSpy.Languages @@ -345,11 +345,11 @@ namespace ICSharpCode.ILSpy.Languages
{
var members = CollectFieldsAndCtors(methodDefinition.DeclaringTypeDefinition!, methodDefinition.IsStatic);
decompiler.AstTransforms.Add(new SelectCtorTransform(methodDefinition));
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(members), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(members));
}
else
{
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(method.MetadataToken), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(method.MetadataToken));
}
OnCSharpDecompiled(output, options);
}
@ -362,7 +362,7 @@ namespace ICSharpCode.ILSpy.Languages @@ -362,7 +362,7 @@ namespace ICSharpCode.ILSpy.Languages
{
CSharpDecompiler decompiler = BeginDecompile(property, output, options);
WriteCommentLine(output, TypeToString(property.DeclaringType));
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(property.MetadataToken), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(property.MetadataToken));
OnCSharpDecompiled(output, options);
}
@ -372,14 +372,14 @@ namespace ICSharpCode.ILSpy.Languages @@ -372,14 +372,14 @@ namespace ICSharpCode.ILSpy.Languages
WriteCommentLine(output, TypeToString(field.DeclaringType));
if (field.IsConst)
{
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(field.MetadataToken), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(field.MetadataToken));
}
else
{
var members = CollectFieldsAndCtors(field.DeclaringTypeDefinition!, field.IsStatic);
var resolvedField = decompiler.TypeSystem.MainModule.GetDefinition((FieldDefinitionHandle)field.MetadataToken);
decompiler.AstTransforms.Add(new SelectFieldTransform(resolvedField));
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(members), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(members));
}
OnCSharpDecompiled(output, options);
}
@ -408,7 +408,7 @@ namespace ICSharpCode.ILSpy.Languages @@ -408,7 +408,7 @@ namespace ICSharpCode.ILSpy.Languages
CSharpDecompiler decompiler = BeginDecompile(extension, output, options);
WriteCommentLine(output, TypeToString(commentType,
ConversionFlags.UseFullyQualifiedTypeNames | ConversionFlags.UseFullyQualifiedEntityNames | ConversionFlags.SupportExtensionDeclarations));
WriteCode(output, options.DecompilerSettings, decompiler.DecompileExtension(extension.MetadataToken), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.DecompileExtension(extension.MetadataToken));
OnCSharpDecompiled(output, options);
}
@ -416,7 +416,7 @@ namespace ICSharpCode.ILSpy.Languages @@ -416,7 +416,7 @@ namespace ICSharpCode.ILSpy.Languages
{
CSharpDecompiler decompiler = BeginDecompile(ev, output, options);
WriteCommentLine(output, TypeToString(ev.DeclaringType));
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(ev.MetadataToken), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(ev.MetadataToken));
OnCSharpDecompiled(output, options);
}
@ -424,7 +424,7 @@ namespace ICSharpCode.ILSpy.Languages @@ -424,7 +424,7 @@ namespace ICSharpCode.ILSpy.Languages
{
CSharpDecompiler decompiler = BeginDecompile(type, output, options);
WriteCommentLine(output, TypeToString(type, ConversionFlags.UseFullyQualifiedTypeNames | ConversionFlags.UseFullyQualifiedEntityNames));
WriteCode(output, options.DecompilerSettings, decompiler.Decompile(type.MetadataToken), decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, decompiler.Decompile(type.MetadataToken));
OnCSharpDecompiled(output, options);
}
@ -511,7 +511,7 @@ namespace ICSharpCode.ILSpy.Languages @@ -511,7 +511,7 @@ namespace ICSharpCode.ILSpy.Languages
SyntaxTree st = options.FullDecompilation
? decompiler.DecompileWholeModuleAsSingleFile()
: decompiler.DecompileModuleAndAssemblyAttributes();
WriteCode(output, options.DecompilerSettings, st, decompiler.TypeSystem);
WriteCode(output, options.DecompilerSettings, decompiler, st);
return null;
}
@ -709,14 +709,25 @@ namespace ICSharpCode.ILSpy.Languages @@ -709,14 +709,25 @@ namespace ICSharpCode.ILSpy.Languages
}
}
static void WriteCode(ITextOutput output, DecompilerSettings settings, SyntaxTree syntaxTree, IDecompilerTypeSystem typeSystem)
static void WriteCode(ITextOutput output, DecompilerSettings settings, CSharpDecompiler decompiler, SyntaxTree syntaxTree)
{
syntaxTree.AcceptVisitor(new InsertParenthesesVisitor { InsertParenthesesForReadability = true });
output.IndentationString = settings.CSharpFormattingOptions.IndentationString;
TokenWriter tokenWriter = new TextTokenWriter(output, settings, typeSystem);
TokenWriter tokenWriter = new TextTokenWriter(output, settings, decompiler.TypeSystem);
if (output is TextView.ISmartTextOutput smartOutput)
tokenWriter = new CSharpHighlightingTokenWriter(tokenWriter, smartOutput);
// For the on-screen C# view, harvest the IL-offset/line map for body bookmarks during this
// single formatting pass (see Bookmarks.BookmarkDebugInfoCollector). The collector is the
// outermost writer so its StartNode sees each node's start line before any token is written.
// Other outputs (IL view, ilspycmd's plain text) are not AvaloniaEditTextOutput and are unaffected.
Bookmarks.BookmarkDebugInfoCollector? bookmarkCollector = null;
if (output is TextView.AvaloniaEditTextOutput bookmarkOutput)
tokenWriter = bookmarkCollector = new Bookmarks.BookmarkDebugInfoCollector(tokenWriter, bookmarkOutput);
syntaxTree.AcceptVisitor(new CSharpOutputVisitor(tokenWriter, settings.CSharpFormattingOptions));
bookmarkCollector?.Publish();
}
void AddWarningMessage(MetadataFile module, ITextOutput output, string line1, string? line2 = null,

157
ILSpy/Properties/Resources.Designer.cs generated

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
//------------------------------------------------------------------------------
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
@ -440,7 +440,160 @@ namespace ICSharpCode.ILSpy.Properties { @@ -440,7 +440,160 @@ namespace ICSharpCode.ILSpy.Properties {
return ResourceManager.GetString("BaseTypes", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Assembly for this bookmark no longer exists on disk, remove bookmark?.
/// </summary>
public static string BookmarkAssemblyMissing {
get {
return ResourceManager.GetString("BookmarkAssemblyMissing", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete Bookmark.
/// </summary>
public static string BookmarkDelete {
get {
return ResourceManager.GetString("BookmarkDelete", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Enable / Disable Bookmark.
/// </summary>
public static string BookmarkDisable {
get {
return ResourceManager.GetString("BookmarkDisable", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Export Bookmarks....
/// </summary>
public static string BookmarkExport {
get {
return ResourceManager.GetString("BookmarkExport", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Export Bookmarks.
/// </summary>
public static string BookmarkExportTitle {
get {
return ResourceManager.GetString("BookmarkExportTitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import Bookmarks....
/// </summary>
public static string BookmarkImport {
get {
return ResourceManager.GetString("BookmarkImport", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Merge.
/// </summary>
public static string BookmarkImportMerge {
get {
return ResourceManager.GetString("BookmarkImportMerge", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace.
/// </summary>
public static string BookmarkImportReplace {
get {
return ResourceManager.GetString("BookmarkImportReplace", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace the current bookmarks with the imported ones, or merge them?.
/// </summary>
public static string BookmarkImportReplaceOrMerge {
get {
return ResourceManager.GetString("BookmarkImportReplaceOrMerge", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Import Bookmarks.
/// </summary>
public static string BookmarkImportTitle {
get {
return ResourceManager.GetString("BookmarkImportTitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Module.
/// </summary>
public static string BookmarkModule {
get {
return ResourceManager.GetString("BookmarkModule", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Next Bookmark.
/// </summary>
public static string BookmarkNextBookmark {
get {
return ResourceManager.GetString("BookmarkNextBookmark", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Next Bookmark in File.
/// </summary>
public static string BookmarkNextInFile {
get {
return ResourceManager.GetString("BookmarkNextInFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Previous Bookmark.
/// </summary>
public static string BookmarkPreviousBookmark {
get {
return ResourceManager.GetString("BookmarkPreviousBookmark", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Previous Bookmark in File.
/// </summary>
public static string BookmarkPreviousInFile {
get {
return ResourceManager.GetString("BookmarkPreviousInFile", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Bookmarks.
/// </summary>
public static string Bookmarks {
get {
return ResourceManager.GetString("Bookmarks", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Toggle Bookmark.
/// </summary>
public static string BookmarkToggle {
get {
return ResourceManager.GetString("BookmarkToggle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to C_lone.
/// </summary>

51
ILSpy/Properties/Resources.resx

@ -165,6 +165,57 @@ Are you sure you want to continue?</value> @@ -165,6 +165,57 @@ Are you sure you want to continue?</value>
<data name="BaseTypes" xml:space="preserve">
<value>Base Types</value>
</data>
<data name="BookmarkAssemblyMissing" xml:space="preserve">
<value>Assembly for this bookmark no longer exists on disk, remove bookmark?</value>
</data>
<data name="BookmarkDelete" xml:space="preserve">
<value>Delete Bookmark</value>
</data>
<data name="BookmarkDisable" xml:space="preserve">
<value>Enable / Disable Bookmark</value>
</data>
<data name="BookmarkExport" xml:space="preserve">
<value>Export Bookmarks...</value>
</data>
<data name="BookmarkExportTitle" xml:space="preserve">
<value>Export Bookmarks</value>
</data>
<data name="BookmarkImport" xml:space="preserve">
<value>Import Bookmarks...</value>
</data>
<data name="BookmarkImportMerge" xml:space="preserve">
<value>Merge</value>
</data>
<data name="BookmarkImportReplace" xml:space="preserve">
<value>Replace</value>
</data>
<data name="BookmarkImportReplaceOrMerge" xml:space="preserve">
<value>Replace the current bookmarks with the imported ones, or merge them?</value>
</data>
<data name="BookmarkImportTitle" xml:space="preserve">
<value>Import Bookmarks</value>
</data>
<data name="BookmarkModule" xml:space="preserve">
<value>Module</value>
</data>
<data name="BookmarkNextBookmark" xml:space="preserve">
<value>Next Bookmark</value>
</data>
<data name="BookmarkNextInFile" xml:space="preserve">
<value>Next Bookmark in File</value>
</data>
<data name="BookmarkPreviousBookmark" xml:space="preserve">
<value>Previous Bookmark</value>
</data>
<data name="BookmarkPreviousInFile" xml:space="preserve">
<value>Previous Bookmark in File</value>
</data>
<data name="BookmarkToggle" xml:space="preserve">
<value>Toggle Bookmark</value>
</data>
<data name="Bookmarks" xml:space="preserve">
<value>Bookmarks</value>
</data>
<data name="C_lone" xml:space="preserve">
<value>C_lone</value>
</data>

16
ILSpy/TextView/AvaloniaEditTextOutput.cs

@ -60,6 +60,10 @@ namespace ICSharpCode.ILSpy.TextView @@ -60,6 +60,10 @@ namespace ICSharpCode.ILSpy.TextView
bool needsIndent;
int lineNumber = 1;
/// <summary>The 1-based line the next written text lands on; used to map captured
/// sequence-point lines (relative to a code block) to absolute document lines.</summary>
public int CurrentLine => lineNumber;
public RichTextModel HighlightingModel { get; } = new RichTextModel();
// The same spans that build HighlightingModel, but holding the SHARED named
@ -83,6 +87,18 @@ namespace ICSharpCode.ILSpy.TextView @@ -83,6 +87,18 @@ namespace ICSharpCode.ILSpy.TextView
/// <summary>Maps reference targets to their definition offsets in the rendered text.</summary>
public DefinitionLookup DefinitionLookup { get; } = new();
readonly List<Bookmarks.MethodDebugInfo> methodDebugInfos = new();
/// <summary>
/// Per-method IL-offset &lt;-&gt; line maps captured during a C# decompile (see
/// <see cref="Bookmarks.MethodDebugInfo"/>). Empty for non-C# output; used to anchor
/// in-method bookmarks by IL offset instead of a fragile line number.
/// </summary>
public IReadOnlyList<Bookmarks.MethodDebugInfo> MethodDebugInfos => methodDebugInfos;
/// <summary>Appends a captured method map; called by the C# language after writing the code.</summary>
public void AddMethodDebugInfo(Bookmarks.MethodDebugInfo info) => methodDebugInfos.Add(info);
readonly List<KeyValuePair<int, Func<Control>>> uiElements = new();
/// <summary>Inline UI elements collected during writing, in offset order. Fed to

24
ILSpy/TextView/DecompilerTabPageModel.cs

@ -183,6 +183,14 @@ namespace ICSharpCode.ILSpy.TextView @@ -183,6 +183,14 @@ namespace ICSharpCode.ILSpy.TextView
[ObservableProperty]
private DefinitionLookup? definitionLookup;
/// <summary>
/// IL-offset &lt;-&gt; line maps for the methods in this document (C# only). Lets bookmarks
/// anchor in-method lines by IL offset and the gutter place their icons. Null for non-C#
/// content; <see cref="Bookmarks.DecompiledDebugInfo.Empty"/> when C# yielded no methods.
/// </summary>
[ObservableProperty]
private Bookmarks.DecompiledDebugInfo? debugInfo;
/// <summary>
/// Inline UI elements (<see cref="ISmartTextOutput.AddUIElement"/>), in offset order.
/// Fed to <see cref="UIElementGenerator"/> by the text view.
@ -229,6 +237,20 @@ namespace ICSharpCode.ILSpy.TextView @@ -229,6 +237,20 @@ namespace ICSharpCode.ILSpy.TextView
/// </summary>
public DecompilerTextViewState? PendingViewState { get; set; }
/// <summary>
/// A bookmark to scroll to once this document is shown. Set by bookmark navigation before the
/// target node is decompiled; the text view computes the line and positions the caret after
/// the new document lands (and clears this), mirroring <see cref="HighlightedReference"/>.
/// </summary>
[ObservableProperty]
private Bookmarks.Bookmark? pendingBookmark;
/// <summary>Toggles a bookmark on the caret line. Set by the text view; used by the bookmarks pane toolbar.</summary>
public System.Action? ToggleBookmarkAtCaret { get; set; }
/// <summary>Moves to the next (true) / previous (false) bookmark within this document. Set by the text view.</summary>
public System.Action<bool>? NavigateBookmarkInFile { get; set; }
/// <summary>
/// Fired when the user clicks a cross-document reference. The host (DockWorkspace)
/// resolves the target on the assembly tree side.
@ -482,6 +504,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -482,6 +504,7 @@ namespace ICSharpCode.ILSpy.TextView
Foldings = null;
References = null;
DefinitionLookup = null;
DebugInfo = null;
UIElements = null;
Text = string.Empty;
IsDecompiling = false;
@ -711,6 +734,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -711,6 +734,7 @@ namespace ICSharpCode.ILSpy.TextView
Foldings = output.Foldings;
References = output.References;
DefinitionLookup = output.DefinitionLookup;
DebugInfo = new Bookmarks.DecompiledDebugInfo(output.MethodDebugInfos);
UIElements = output.UIElements;
Text = text;
}

173
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -93,6 +93,8 @@ namespace ICSharpCode.ILSpy.TextView @@ -93,6 +93,8 @@ namespace ICSharpCode.ILSpy.TextView
// text. Position-relative menu entries (e.g. "Toggle folding") read this so they act on the
// clicked line rather than wherever the caret happens to sit.
int? lastRightClickedOffset;
int lastRightClickedLine = -1;
Bookmarks.BookmarkMargin? bookmarkMargin;
IReadOnlyList<IContextMenuEntryExport> contextMenuEntries = Array.Empty<IContextMenuEntryExport>();
Popup richPopup = null!;
double distanceToPopupLimit;
@ -138,6 +140,14 @@ namespace ICSharpCode.ILSpy.TextView @@ -138,6 +140,14 @@ namespace ICSharpCode.ILSpy.TextView
// Ctrl+L focuses the omnibar into search mode (browser address-bar gesture). Tunnel so
// it wins before AvaloniaEdit's own key handling while focus is anywhere in the editor.
AddHandler(KeyDownEvent, OnPreviewKeyDownForOmnibar, RoutingStrategies.Tunnel);
// Ctrl+B toggles a bookmark on the caret line. Bubble so normal editor keys keep working.
AddHandler(KeyDownEvent, OnBookmarkKeyDown, RoutingStrategies.Bubble);
// Bookmark icon gutter, leftmost (before the line numbers). It draws nothing unless the
// current document is C# and has bookmarks, so it is harmless on IL / metadata views.
bookmarkMargin = new Bookmarks.BookmarkMargin(this);
Editor.TextArea.LeftMargins.Insert(0, bookmarkMargin);
}
void OnPreviewKeyDownForOmnibar(object? sender, KeyEventArgs e)
@ -606,6 +616,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -606,6 +616,7 @@ namespace ICSharpCode.ILSpy.TextView
if (!e.GetCurrentPoint(Editor.TextArea.TextView).Properties.IsRightButtonPressed)
return;
var pos = GetPositionFromPointer(e);
lastRightClickedLine = pos?.Line ?? -1;
if (pos == null)
{
lastRightClickedSegment = null;
@ -648,6 +659,154 @@ namespace ICSharpCode.ILSpy.TextView @@ -648,6 +659,154 @@ namespace ICSharpCode.ILSpy.TextView
}
}
#region Bookmarks
Bookmarks.BookmarkManager? bookmarkManager;
Bookmarks.BookmarkManager? BookmarkManager
=> bookmarkManager ??= AppEnv.AppComposition.TryGetExport<Bookmarks.BookmarkManager>();
// Bookmarks live only on the decompiled C# view, not on IL / metadata / resource output.
bool ShowsBookmarkableCode => DataContext is DecompilerTabPageModel { SyntaxExtension: ".cs" };
Bookmarks.Bookmark? CreateBookmarkForLine(int line)
{
if (!ShowsBookmarkableCode || DataContext is not DecompilerTabPageModel model)
return null;
return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line);
}
/// <summary>Whether <paramref name="line"/> is a statement or definition line that can hold a bookmark.</summary>
internal bool CanToggleBookmarkAtLine(int line) => CreateBookmarkForLine(line) != null;
/// <summary>Adds or removes a bookmark on <paramref name="line"/>; a no-op for non-anchorable lines.</summary>
internal void ToggleBookmarkAtLine(int line)
{
if (CreateBookmarkForLine(line) is { } candidate)
BookmarkManager?.Toggle(candidate);
}
/// <summary>True when the line under the last right-click can hold a bookmark; drives the context-menu entry.</summary>
internal bool CanToggleBookmarkAtRightClick => lastRightClickedLine >= 1 && CanToggleBookmarkAtLine(lastRightClickedLine);
/// <summary>Toggles a bookmark on the line under the last right-click (the context-menu target).</summary>
internal void ToggleBookmarkAtRightClick()
{
if (lastRightClickedLine >= 1)
ToggleBookmarkAtLine(lastRightClickedLine);
}
void OnBookmarkKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.B && e.KeyModifiers == KeyModifiers.Control && Editor.TextArea.IsKeyboardFocusWithin)
{
ToggleBookmarkAtLine(Editor.TextArea.Caret.Line);
e.Handled = true;
}
}
// Wired onto the model so the bookmarks-pane toolbar can act on the active document.
void ToggleBookmarkAtCaret() => ToggleBookmarkAtLine(Editor.TextArea.Caret.Line);
// Moves the caret to the next/previous bookmark in this document, ordered by line and relative
// to the caret (wrapping around). A no-op when the document holds fewer than one bookmark.
void NavigateBookmarkInFile(bool forward)
{
if (BookmarkManager is not { } manager)
return;
var lines = new List<int>();
foreach (var bookmark in manager.Bookmarks)
{
// Disabled bookmarks stay visible in the gutter but are skipped by next/previous.
if (bookmark.Enabled && GetLineForBookmark(bookmark) is { } line)
lines.Add(line);
}
if (lines.Count == 0)
return;
lines.Sort();
int caretLine = Editor.TextArea.Caret.Line;
int target = forward
? lines.FirstOrDefault(l => l > caretLine, lines[0])
: lines.LastOrDefault(l => l < caretLine, lines[^1]);
ScrollToLine(target);
}
void ApplyPendingBookmark(DecompilerTabPageModel model)
{
if (model.PendingBookmark is not { } bookmark)
return;
if (GetLineForBookmark(bookmark) is { } line)
{
ScrollToLine(line);
model.PendingBookmark = null;
}
}
void ScrollToLine(int line)
{
line = Math.Clamp(line, 1, Editor.Document.LineCount);
Editor.TextArea.Caret.Offset = Editor.Document.GetLineByNumber(line).Offset;
// Centre the line and pulse its gutter icon once the layout has caught up. Posting lets a
// just-applied document finish measuring, so the visual position is accurate either way --
// whether we got here after a fresh decompile or while the document was already on screen.
Dispatcher.UIThread.Post(() => {
CenterLineInView(line);
LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line);
bookmarkMargin?.PulseLine(line);
}, DispatcherPriority.Background);
}
// Scrolls so <paramref name="line"/> sits in the middle of the viewport. AvaloniaEdit's
// ScrollTo* are no-ops in 12.0.0 (#594), so set the ScrollViewer offset directly.
void CenterLineInView(int line)
{
if (EditorScrollViewer is not { } scrollViewer)
return;
var textView = Editor.TextArea.TextView;
double visualTop = textView.GetVisualTopByDocumentLine(line);
double target = visualTop - (scrollViewer.Viewport.Height - textView.DefaultLineHeight) / 2;
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Max(0, target));
}
/// <summary>
/// The document line a bookmark sits on in the currently shown C# document, or null when the
/// bookmark belongs to other code. Body anchors resolve via the IL-offset map; token anchors
/// via the definition's position. The module identity is verified so a token value shared
/// across assemblies can't place an icon on the wrong line.
/// </summary>
internal int? GetLineForBookmark(Bookmarks.Bookmark bookmark)
{
if (DataContext is not DecompilerTabPageModel { SyntaxExtension: ".cs" } model)
return null;
if (bookmark.Kind == Bookmarks.BookmarkKind.Body)
{
if (model.DebugInfo is not { } debug)
return null;
foreach (var method in debug.Methods)
{
if (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName
&& method.TryGetLineForOffset(bookmark.ILOffset, out var bodyLine))
return bodyLine;
}
return null;
}
if (model.References is { } references)
{
foreach (var segment in references)
{
if (segment.IsDefinition && segment.Reference is IEntity entity
&& (uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token
&& entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName)
return Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber;
}
}
return null;
}
#endregion
protected override void OnDataContextChanged(System.EventArgs e)
{
base.OnDataContextChanged(e);
@ -658,6 +817,8 @@ namespace ICSharpCode.ILSpy.TextView @@ -658,6 +817,8 @@ namespace ICSharpCode.ILSpy.TextView
{
previous.PropertyChanged -= OnModelPropertyChanged;
previous.CaptureViewState = null;
previous.ToggleBookmarkAtCaret = null;
previous.NavigateBookmarkInFile = null;
}
boundModel = DataContext as DecompilerTabPageModel;
@ -668,6 +829,9 @@ namespace ICSharpCode.ILSpy.TextView @@ -668,6 +829,9 @@ namespace ICSharpCode.ILSpy.TextView
// records a navigation away. (Re)assigning every DataContext-change handles both
// the first attach and an ABA reattach.
model.CaptureViewState = GetCurrentViewState;
// Bookmarks-pane toolbar actions that operate on the active document route through these.
model.ToggleBookmarkAtCaret = ToggleBookmarkAtCaret;
model.NavigateBookmarkInFile = NavigateBookmarkInFile;
ApplyDocument(model);
// Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the
// node rather than letting it inherit the document DataContext).
@ -723,6 +887,12 @@ namespace ICSharpCode.ILSpy.TextView @@ -723,6 +887,12 @@ namespace ICSharpCode.ILSpy.TextView
{
ApplyHighlightedReference(m);
}
// A bookmark navigation that lands on the already-open document (no re-decompile) scrolls here.
else if (sender is DecompilerTabPageModel bm
&& e.PropertyName == nameof(DecompilerTabPageModel.PendingBookmark))
{
ApplyPendingBookmark(bm);
}
}
void ApplyHighlightedReference(DecompilerTabPageModel model)
@ -774,6 +944,9 @@ namespace ICSharpCode.ILSpy.TextView @@ -774,6 +944,9 @@ namespace ICSharpCode.ILSpy.TextView
if (restoreViewState)
RestoreOrResetViewState(pendingState);
// Position at a navigated-to bookmark once its document (and debug map) has landed.
ApplyPendingBookmark(model);
SwapCustomElementGenerators(model.CustomElementGenerators);
Editor.TextArea.TextView.Redraw();

106
ILSpy/TextView/LineHighlightAdorner.cs

@ -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);
}
}
}

2
ILSpy/ViewLocator.cs

@ -27,6 +27,7 @@ using Dock.Model.Core; @@ -27,6 +27,7 @@ using Dock.Model.Core;
using ICSharpCode.ILSpy.Analyzers;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Bookmarks;
using ICSharpCode.ILSpy.Compare;
using ICSharpCode.ILSpy.Options;
using ICSharpCode.ILSpy.Search;
@ -62,6 +63,7 @@ namespace ICSharpCode.ILSpy @@ -62,6 +63,7 @@ namespace ICSharpCode.ILSpy
static readonly Dictionary<Type, Func<Control>> s_views = new() {
{ typeof(AssemblyTreeModel), () => new AssemblyListPane() },
{ typeof(SearchPaneModel), () => new SearchPane() },
{ typeof(BookmarksPaneModel), () => new BookmarksPane() },
{ typeof(AnalyzerTreeViewModel), () => new AnalyzerTreeView() },
{ typeof(ContentTabPage), () => new ContentTabPageView() },
{ typeof(DecompilerTabPageModel), () => new DecompilerTextView() },

Loading…
Cancel
Save