Browse Source

Complete bookmark backlog follow-ups

Bookmarks now capture and restore the requested decompiler view state, support rendered-line fallback anchors, expose hover and pane location affordances, route bookmark context-menu actions through the clicked offset, and update the JSON sidecar under a mutex so concurrent instances do not overwrite unrelated bookmark edits.

The plan file records the completed checklist and the remaining manual smoke-test gap.

Assisted-by: CodeAlta:gpt-5.5:CodeAlta
pull/3839/head
Siegfried Pammer 2 weeks ago committed by Siegfried Pammer
parent
commit
8997b42f2b
  1. 10
      ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs
  2. 138
      ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs
  3. 137
      ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs
  4. 129
      ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs
  5. 79
      ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs
  6. 4
      ILSpy/App.axaml
  7. 4
      ILSpy/AppEnv/ConfigurationFiles.cs
  8. 1
      ILSpy/Assets/Icons/Bookmark.GroupDisable.svg
  9. 1
      ILSpy/Assets/Icons/Bookmark.Next.Folder.svg
  10. 42
      ILSpy/Assets/Icons/Bookmark.Previous.Folder.svg
  11. 1
      ILSpy/Assets/Icons/Bookmark.Window.svg
  12. 56
      ILSpy/Bookmarks/Bookmark.cs
  13. 48
      ILSpy/Bookmarks/BookmarkAnchoring.cs
  14. 152
      ILSpy/Bookmarks/BookmarkManager.cs
  15. 115
      ILSpy/Bookmarks/BookmarkMargin.cs
  16. 43
      ILSpy/Bookmarks/BookmarkNavigator.cs
  17. 100
      ILSpy/Bookmarks/BookmarkViewState.cs
  18. 91
      ILSpy/Bookmarks/BookmarksPane.axaml
  19. 3
      ILSpy/Bookmarks/BookmarksPaneModel.cs
  20. 12
      ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs
  21. 2
      ILSpy/Images.cs
  22. 3
      ILSpy/TextView/DecompilerTabPageModel.cs
  23. 148
      ILSpy/TextView/DecompilerTextView.axaml.cs

10
ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs

@ -70,7 +70,13 @@ public class BookmarkAnchoringTests @@ -70,7 +70,13 @@ public class BookmarkAnchoringTests
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();
// Line 1 is the "// <assembly name>" comment: neither a statement nor a definition,
// so it falls back to the visible line in the current decompiled document.
var fallback = BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, 1, type);
fallback.Should().NotBeNull();
fallback!.Kind.Should().Be(BookmarkKind.Line);
fallback.LineNumber.Should().Be(1);
fallback.Token.Should().Be((uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(type.MetadataToken));
}
}

138
ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs

@ -0,0 +1,138 @@ @@ -0,0 +1,138 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AvaloniaEdit.Document;
using AwesomeAssertions;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Bookmarks;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Bookmarks;
[TestFixture]
public class BookmarkContextMenuTests
{
[AvaloniaTest]
public async Task Toggle_Bookmark_Entry_Acts_On_The_Right_Clicked_Line_Not_The_Caret()
{
var (window, vm) = await TestHarness.BootAsync(3);
var manager = AppComposition.Current.GetExport<BookmarkManager>();
manager.Clear();
var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
var bookmarkableLines = Enumerable.Range(1, view.Editor.Document.LineCount)
.Where(view.CanToggleBookmarkAtLine)
.Take(2)
.ToArray();
bookmarkableLines.Should().HaveCount(2, "two distinct bookmarkable lines are needed to tell the caret from the click");
int caretLine = bookmarkableLines[0];
int clickedLine = bookmarkableLines[1];
view.Editor.TextArea.Caret.Offset = view.Editor.Document.GetLineByNumber(caretLine).Offset;
int clickedOffset = view.Editor.Document.GetLineByNumber(clickedLine).Offset;
var toggle = AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.BookmarkToggle));
toggle.Execute(new TextViewContext { TextView = view, TextLocation = clickedOffset });
Dispatcher.UIThread.RunJobs();
manager.Bookmarks.Should().ContainSingle();
view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(clickedLine);
manager.Bookmarks[0].LocationNodeName.Should().Be("System.Object");
}
[AvaloniaTest]
public async Task Navigation_Does_Not_Fall_Back_To_Entity_When_Saved_Tree_Path_Is_Missing()
{
var (window, vm) = await TestHarness.BootAsync(3);
var manager = AppComposition.Current.GetExport<BookmarkManager>();
manager.Clear();
var coreLibName = typeof(object).Assembly.GetName().Name!;
var objectNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(objectNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
int line = Enumerable.Range(1, view.Editor.Document.LineCount)
.First(view.CanToggleBookmarkAtLine);
int offset = view.Editor.Document.GetLineByNumber(line).Offset;
AppComposition.Current.GetExport<ContextMenuEntryRegistry>()
.GetEntry(nameof(Resources.BookmarkToggle))
.Execute(new TextViewContext { TextView = view, TextLocation = offset });
Dispatcher.UIThread.RunJobs();
manager.Bookmarks.Should().ContainSingle();
var bookmark = manager.Bookmarks[0];
bookmark.ViewState.Should().NotBeNull();
bookmark.ViewState = bookmark.ViewState! with { SelectedTreeNodePath = new[] { "Missing assembly", "Missing type" } };
var stringNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.String");
vm.AssemblyTreeModel.SelectNode(stringNode);
await AppComposition.Current.GetExport<BookmarkNavigator>().NavigateToAsync(bookmark);
((object?)vm.AssemblyTreeModel.SelectedItem).Should().BeSameAs(stringNode);
}
[AvaloniaTest]
public async Task Queued_Bookmark_Scroll_Ignores_A_Replaced_Document()
{
var (window, _) = await TestHarness.BootAsync(3);
var view = await window.WaitForComponent<DecompilerTextView>();
view.Editor.Document = new TextDocument(string.Join("\n", Enumerable.Range(1, 30).Select(i => $"line {i}")));
var scrollToLine = typeof(DecompilerTextView).GetMethod("ScrollToLine", BindingFlags.Instance | BindingFlags.NonPublic);
scrollToLine.Should().NotBeNull();
scrollToLine!.Invoke(view, new object?[] { 20, null });
view.Editor.Document = new TextDocument("replacement");
Dispatcher.UIThread.RunJobs();
}
}

137
ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs

@ -0,0 +1,137 @@ @@ -0,0 +1,137 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Headless;
using Avalonia.Headless.NUnit;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Threading;
using AvaloniaEdit.Rendering;
using AwesomeAssertions;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.Bookmarks;
using ICSharpCode.ILSpy.TextView;
using ICSharpCode.ILSpy.TreeNodes;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Bookmarks;
[TestFixture]
public class BookmarkGutterTests
{
[AvaloniaTest]
public async Task Clicking_Bookmark_Gutter_Toggles_The_Visible_Line()
{
var (window, vm) = await TestHarness.BootAsync(3);
var manager = AppComposition.Current.GetExport<BookmarkManager>();
manager.Clear();
var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
window.UpdateLayout();
var margin = view.Editor.TextArea.LeftMargins.OfType<BookmarkMargin>().Single();
margin.Bounds.Width.Should().BeGreaterThan(0, "the bookmark gutter must reserve hit-testable space");
var textView = view.Editor.TextArea.TextView;
textView.EnsureVisualLines();
var visualLine = textView.VisualLines.First(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber));
int documentLine = visualLine.FirstDocumentLine.LineNumber;
double y = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset;
y.Should().BeInRange(0, margin.Bounds.Height, "the selected visual line must be inside the gutter bounds");
var point = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window);
point.Should().NotBeNull("the gutter line coordinate must map into the test window");
int pressed = 0;
margin.AddHandler(InputElement.PointerPressedEvent, (_, _) => pressed++, RoutingStrategies.Tunnel | RoutingStrategies.Bubble);
window.MouseDown(point!.Value, MouseButton.Left);
window.MouseUp(point.Value, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
pressed.Should().BeGreaterThan(0, "the real pointer event must hit the bookmark gutter");
manager.Bookmarks.Should().ContainSingle();
view.GetLineForBookmark(manager.Bookmarks[0]).Should().Be(documentLine);
window.MouseDown(point.Value, MouseButton.Left);
window.MouseUp(point.Value, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
manager.Bookmarks.Should().BeEmpty();
}
[AvaloniaTest]
public async Task RightClicking_Bookmark_Gutter_Does_Not_Toggle()
{
var (window, vm) = await TestHarness.BootAsync(3);
var manager = AppComposition.Current.GetExport<BookmarkManager>();
manager.Clear();
var coreLibName = typeof(object).Assembly.GetName().Name!;
var typeNode = vm.AssemblyTreeModel.FindNode<TypeTreeNode>(coreLibName, "System", "System.Object");
vm.AssemblyTreeModel.SelectNode(typeNode);
await vm.DockWorkspace.WaitForDecompiledTextAsync();
var view = await window.WaitForComponent<DecompilerTextView>();
for (int i = 0; i < 8; i++)
{
Dispatcher.UIThread.RunJobs();
await Task.Delay(25);
}
window.UpdateLayout();
var margin = view.Editor.TextArea.LeftMargins.OfType<BookmarkMargin>().Single();
var textView = view.Editor.TextArea.TextView;
textView.EnsureVisualLines();
var visualLine = textView.VisualLines.First(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber));
double y = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset;
var point = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window);
point.Should().NotBeNull("the gutter line coordinate must map into the test window");
window.MouseDown(point!.Value, MouseButton.Right);
window.MouseUp(point.Value, MouseButton.Right);
Dispatcher.UIThread.RunJobs();
manager.Bookmarks.Should().BeEmpty("a right-click in the gutter must not toggle a bookmark");
// The same coordinate still toggles on a left-click, proving the gesture is button-gated
// rather than disabled at that line.
window.MouseDown(point.Value, MouseButton.Left);
window.MouseUp(point.Value, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
manager.Bookmarks.Should().ContainSingle("a left-click at the same coordinate still toggles");
}
}

129
ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs

@ -70,7 +70,7 @@ public class BookmarkManagerTests @@ -70,7 +70,7 @@ public class BookmarkManagerTests
return new BookmarkManager();
}
static Bookmark MakeBookmark(uint token, BookmarkKind kind = BookmarkKind.Token, int ilOffset = 0, string name = "")
static Bookmark MakeBookmark(uint token, BookmarkKind kind = BookmarkKind.Token, int ilOffset = 0, string name = "", int lineNumber = 1, string? locationNodeName = null)
=> new() {
Name = name,
FileName = @"C:\asm\Sample.dll",
@ -79,7 +79,9 @@ public class BookmarkManagerTests @@ -79,7 +79,9 @@ public class BookmarkManagerTests
Token = token,
Kind = kind,
ILOffset = ilOffset,
LineNumber = lineNumber,
MemberName = "Sample.Type.Member",
LocationNodeName = locationNodeName,
};
[Test]
@ -116,6 +118,17 @@ public class BookmarkManagerTests @@ -116,6 +118,17 @@ public class BookmarkManagerTests
manager.Bookmarks.Should().HaveCount(2);
}
[Test]
public void Line_anchors_with_different_visible_lines_are_distinct()
{
var manager = new BookmarkManager();
manager.Toggle(MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 3));
manager.Toggle(MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 4));
manager.Bookmarks.Should().HaveCount(2);
}
[Test]
public void Saved_bookmarks_reload_in_a_fresh_manager()
{
@ -132,6 +145,120 @@ public class BookmarkManagerTests @@ -132,6 +145,120 @@ public class BookmarkManagerTests
reloaded.ILOffset.Should().Be(4);
}
[Test]
public void Saved_line_bookmarks_reload_in_a_fresh_manager()
{
var first = new BookmarkManager();
first.Toggle(MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 7, name: "Header", locationNodeName: "Sample.Type"));
var second = new BookmarkManager();
second.Bookmarks.Should().ContainSingle();
var reloaded = second.Bookmarks[0];
reloaded.Kind.Should().Be(BookmarkKind.Line);
reloaded.LineNumber.Should().Be(7);
reloaded.LocationNodeName.Should().Be("Sample.Type");
}
[Test]
public void Saved_bookmarks_reload_their_view_state_payload()
{
var first = new BookmarkManager();
var bookmark = MakeBookmark(0x06000001, name: "With view");
bookmark.ViewState = new BookmarkViewState(1, 42, 120.5, 7.25, 100,
new[] { new BookmarkFoldingRange(10, 20) }, SelectedTreeNodePath: new[] { "Sample", "Sample.Type" });
first.Toggle(bookmark);
var second = new BookmarkManager();
second.Bookmarks.Should().ContainSingle();
second.Bookmarks[0].ViewState.Should().NotBeNull();
second.Bookmarks[0].ViewState!.CaretOffset.Should().Be(42);
second.Bookmarks[0].ViewState!.ExpandedFoldings.Should().ContainSingle()
.Which.Should().Be(new BookmarkFoldingRange(10, 20));
second.Bookmarks[0].ViewState!.SelectedTreeNodePath.Should().Equal("Sample", "Sample.Type");
}
[Test]
public void Display_location_shows_node_name_and_line()
{
var bookmark = MakeBookmark(0x02000001, BookmarkKind.Line, lineNumber: 7, locationNodeName: "Sample.Type.Node");
bookmark.ViewState = new BookmarkViewState(1, 42, 120.5, 7.25, null, null,
SelectedTreeNodePath: new[] { "Sample", "Sample.Type" });
bookmark.DisplayLocation.Should().Be("Sample.Type.Node:7");
}
[Test]
public void Display_location_uses_resolved_rendered_line_when_it_was_not_stored()
{
var bookmark = MakeBookmark(0x02000001, BookmarkKind.Token, lineNumber: 0, locationNodeName: "Sample.Type.Node");
bookmark.DisplayLocation.Should().Be("Sample.Type.Node");
bookmark.UpdateRenderedLineNumber(59);
bookmark.LineNumber.Should().Be(59);
bookmark.DisplayLocation.Should().Be("Sample.Type.Node:59");
}
[Test]
public void Two_managers_adding_different_bookmarks_preserve_both_entries()
{
var first = new BookmarkManager();
var second = new BookmarkManager();
first.Toggle(MakeBookmark(0x06000001, name: "First"));
second.Toggle(MakeBookmark(0x06000002, name: "Second"));
var reloaded = new BookmarkManager();
reloaded.Bookmarks.Select(b => b.Name).Should().BeEquivalentTo("First", "Second");
}
[Test]
public void Two_managers_editing_the_same_anchor_keep_the_later_value()
{
var first = new BookmarkManager();
first.Toggle(MakeBookmark(0x06000001, name: "Original"));
var second = new BookmarkManager();
first.Bookmarks[0].Name = "First edit";
second.Bookmarks[0].Name = "Second edit";
var reloaded = new BookmarkManager();
reloaded.Bookmarks.Should().ContainSingle();
reloaded.Bookmarks[0].Name.Should().Be("Second edit");
}
[Test]
public void Two_managers_remove_and_add_preserve_the_added_bookmark()
{
var first = new BookmarkManager();
first.Toggle(MakeBookmark(0x06000001, name: "Remove me"));
var second = new BookmarkManager();
first.Remove(first.Bookmarks[0]);
second.Toggle(MakeBookmark(0x06000002, name: "Keep me"));
var reloaded = new BookmarkManager();
reloaded.Bookmarks.Select(b => b.Name).Should().Equal("Keep me");
}
[Test]
public void Malformed_bookmark_file_recovers_on_next_update()
{
File.WriteAllText(Path.Combine(tempDir, "ILSpy.Bookmarks.json"), "not json");
var manager = new BookmarkManager();
manager.Toggle(MakeBookmark(0x06000001, name: "Recovered"));
var reloaded = new BookmarkManager();
reloaded.Bookmarks.Select(b => b.Name).Should().Equal("Recovered");
}
[Test]
public void Bookmarks_without_a_file_are_dropped_on_load()
{

79
ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Linq;
using Avalonia.Controls;
using Avalonia.Headless.NUnit;
using Avalonia.Threading;
using AwesomeAssertions;
using ICSharpCode.ILSpy.Bookmarks;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpy.Views.Controls;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Bookmarks;
[TestFixture]
public class BookmarksPaneStructureTests
{
[AvaloniaTest]
public void Toolbar_uses_main_toolbar_chrome_and_button_content()
{
var pane = new BookmarksPane();
var toolbarBorder = pane.FindControl<Border>("ToolbarBorder")!;
var toolbarRoot = pane.FindControl<StackPanel>("ToolbarRoot")!;
toolbarBorder.BorderThickness.Should().Be(new Avalonia.Thickness(0, 0, 0, 1));
toolbarBorder.MinHeight.Should().Be(29);
toolbarBorder.Padding.Should().Be(new Avalonia.Thickness(3));
toolbarRoot.Children.OfType<Separator>().Should().HaveCount(2);
toolbarRoot.Children.OfType<Button>().Should().AllSatisfy(button => {
button.Content.Should().BeOfType<GrayscaleAwareImage>();
});
}
[AvaloniaTest]
public void Module_column_shows_module_name_with_full_path_tooltip()
{
var pane = new BookmarksPane();
var grid = pane.FindControl<DataGrid>("BookmarkGrid")!;
var column = grid.Columns.OfType<DataGridTemplateColumn>()
.Single(c => Equals(c.Header, Resources.BookmarkModule));
var bookmark = new Bookmark {
Name = "Bookmark0",
FileName = @"C:\assemblies\Sample.dll",
AssemblyFullName = "Sample, Version=1.0.0.0",
ModuleName = "Sample.dll",
Token = 0x02000001,
Kind = BookmarkKind.Token,
LineNumber = 1,
MemberName = "Sample.Type",
};
var textBlock = column.CellTemplate!.Build(bookmark).Should().BeOfType<TextBlock>().Subject;
textBlock.DataContext = bookmark;
Dispatcher.UIThread.RunJobs();
textBlock.Text.Should().Be("Sample.dll");
ToolTip.GetTip(textBlock).Should().Be(@"C:\assemblies\Sample.dll");
}
}

4
ILSpy/App.axaml

@ -90,6 +90,8 @@ @@ -90,6 +90,8 @@
<SolidColorBrush x:Key="ILSpy.FoldingMarkerForeground" Color="#808080" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerSelectedBackground" Color="White" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerSelectedForeground" Color="Black" />
<SolidColorBrush x:Key="ILSpy.BookmarkGutterBackground" Color="#F3F0D0" />
<SolidColorBrush x:Key="ILSpy.BookmarkGutterBorder" Color="#FFC8CDD3" />
<!-- Omnibar (breadcrumb / search bar atop the decompiled view): a faint surface
set off from the cream editor canvas, with the toolbar accent for crumb hover. -->
<SolidColorBrush x:Key="ILSpy.OmnibarBackground" Color="#F3F3F3" />
@ -148,6 +150,8 @@ @@ -148,6 +150,8 @@
<SolidColorBrush x:Key="ILSpy.FoldingMarkerForeground" Color="#A0A0A0" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerSelectedBackground" Color="#2D2D30" />
<SolidColorBrush x:Key="ILSpy.FoldingMarkerSelectedForeground" Color="#DCDCDC" />
<SolidColorBrush x:Key="ILSpy.BookmarkGutterBackground" Color="#252526" />
<SolidColorBrush x:Key="ILSpy.BookmarkGutterBorder" Color="#3F3F46" />
<!-- Omnibar: a step lighter than the dark editor canvas so the bar reads as
chrome, sharing the toolbar accent for crumb hover. -->
<SolidColorBrush x:Key="ILSpy.OmnibarBackground" Color="#2D2D30" />

4
ILSpy/AppEnv/ConfigurationFiles.cs

@ -25,8 +25,8 @@ namespace ICSharpCode.ILSpy.AppEnv @@ -25,8 +25,8 @@ 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.
/// 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
{

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

@ -1 +0,0 @@ @@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 912 B

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

@ -1 +0,0 @@ @@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1008 B

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

@ -1,42 +0,0 @@ @@ -1,42 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

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

@ -1 +0,0 @@ @@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 645 B

56
ILSpy/Bookmarks/Bookmark.cs

@ -16,14 +16,16 @@ @@ -16,14 +16,16 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Globalization;
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.
/// How a <see cref="Bookmark"/> is anchored to a decompiled member. Prefer semantic anchors
/// (metadata token or method IL offset) and use the rendered line only when no stable semantic
/// anchor exists for the clicked line.
/// </summary>
public enum BookmarkKind
{
@ -31,7 +33,10 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -31,7 +33,10 @@ namespace ICSharpCode.ILSpy.Bookmarks
Token,
/// <summary>A line inside a method body: module + method token + IL offset.</summary>
Body
Body,
/// <summary>A displayed line without a stable semantic anchor: module + containing token + visible line number.</summary>
Line
}
/// <summary>
@ -41,6 +46,8 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -41,6 +46,8 @@ namespace ICSharpCode.ILSpy.Bookmarks
/// </summary>
public sealed partial class Bookmark : ObservableObject
{
int lineNumber;
[ObservableProperty]
private string name = "";
@ -64,18 +71,53 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -64,18 +71,53 @@ namespace ICSharpCode.ILSpy.Bookmarks
/// <summary>IL offset within the method body; meaningful only for <see cref="BookmarkKind.Body"/>.</summary>
public int ILOffset { get; init; }
/// <summary>Visible document line number captured in the rendered text.</summary>
public int LineNumber {
get => lineNumber;
init => lineNumber = value;
}
/// <summary>
/// Display name of the anchored member, shown in the pane's "Location" column. Navigation
/// Display name of the anchored member. Navigation
/// resolves purely by token and does not compare this name: a token can legitimately point at
/// a compiler-generated member (e.g. a local function) whose resolved name differs from this
/// stored display name, and a name check would wrongly reject that valid navigation.
/// </summary>
public required string MemberName { get; init; }
/// <summary>Full display name of the tree node whose rendered text contains the bookmark.</summary>
public string? LocationNodeName { get; init; }
/// <summary>Decompiler editor view state captured when the bookmark was created.</summary>
public BookmarkViewState? ViewState { get; set; }
/// <summary>Location text shown in the bookmarks pane.</summary>
public string DisplayLocation {
get {
var nodeName = !string.IsNullOrEmpty(LocationNodeName)
? LocationNodeName
: ViewState?.SelectedTreeNodePath is { Count: > 0 } path
? path[^1]
: MemberName;
return LineNumber > 0
? string.Create(CultureInfo.InvariantCulture, $"{nodeName}:{LineNumber}")
: nodeName;
}
}
/// <summary>Updates the rendered line shown in the bookmarks pane after the current document resolves it.</summary>
public void UpdateRenderedLineNumber(int lineNumber)
{
if (lineNumber < 1 || this.lineNumber == lineNumber)
return;
this.lineNumber = lineNumber;
OnPropertyChanged(nameof(DisplayLocation));
}
/// <summary>
/// Identity used for toggle and merge-import deduplication: same assembly + same location
/// (token, plus IL offset for body anchors) means the same bookmark.
/// (token, plus IL offset or rendered line for anchors that need one) means the same bookmark.
/// </summary>
public string AnchorKey => $"{AssemblyFullName}|{ModuleName}|{Token}|{(Kind == BookmarkKind.Body ? ILOffset : -1)}";
public string AnchorKey => $"{AssemblyFullName}|{ModuleName}|{Kind}|{Token}|{Kind switch { BookmarkKind.Body => ILOffset, BookmarkKind.Line => LineNumber, _ => -1 }}";
}
}

48
ILSpy/Bookmarks/BookmarkAnchoring.cs

@ -29,44 +29,78 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -29,44 +29,78 @@ 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.
/// anchor; other displayed lines fall back to their current visible line number.
/// </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).
/// is outside the current document or no containing entity can be identified.
/// </summary>
public static Bookmark? CreateForLine(DecompiledDebugInfo? debugInfo,
TextSegmentCollection<ReferenceSegment>? references, TextDocument document, int line)
TextSegmentCollection<ReferenceSegment>? references, TextDocument document, int line,
IEntity? fallbackOwner = null, string? locationNodeName = null)
{
if (document == null || line < 1 || line > document.LineCount)
return null;
if (debugInfo != null && debugInfo.TryGetBodyAnchor(line, out var method, out var ilOffset))
{
return new Bookmark {
Kind = BookmarkKind.Body,
Token = method.Token,
ILOffset = ilOffset,
LineNumber = line,
FileName = method.FileName,
AssemblyFullName = method.AssemblyFullName,
ModuleName = method.ModuleName,
MemberName = method.MemberName,
LocationNodeName = locationNodeName,
};
}
if (references != null && document != null && line >= 1 && line <= document.LineCount)
if (references != null)
{
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)
if (segment.IsDefinition && segment.Reference is IEntity entity && CreateForEntity(entity, line, locationNodeName) is { } bookmark)
return bookmark;
}
}
return CreateForFallbackLine(fallbackOwner ?? FindFirstEntity(references), line, locationNodeName);
}
static IEntity? FindFirstEntity(TextSegmentCollection<ReferenceSegment>? references)
{
if (references == null)
return null;
foreach (var segment in references)
{
if (segment.Reference is IEntity entity)
return entity;
}
return null;
}
static Bookmark? CreateForFallbackLine(IEntity? entity, int line, string? locationNodeName)
{
if (entity == null || CreateForEntity(entity, line, locationNodeName) is not { } bookmark)
return null;
return new Bookmark {
Kind = BookmarkKind.Line,
Token = bookmark.Token,
LineNumber = line,
FileName = bookmark.FileName,
AssemblyFullName = bookmark.AssemblyFullName,
ModuleName = bookmark.ModuleName,
MemberName = bookmark.MemberName,
LocationNodeName = bookmark.LocationNodeName,
};
}
static Bookmark? CreateForEntity(IEntity entity)
static Bookmark? CreateForEntity(IEntity entity, int line, string? locationNodeName)
{
var file = entity.ParentModule?.MetadataFile;
if (file == null)
@ -75,10 +109,12 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -75,10 +109,12 @@ namespace ICSharpCode.ILSpy.Bookmarks
return new Bookmark {
Kind = BookmarkKind.Token,
Token = (uint)MetadataTokens.GetToken(entity.MetadataToken),
LineNumber = line,
FileName = file.FileName,
AssemblyFullName = file.FullName,
ModuleName = moduleName,
MemberName = entity.FullName,
LocationNodeName = locationNodeName,
};
}
}

152
ILSpy/Bookmarks/BookmarkManager.cs

@ -27,6 +27,7 @@ using System.IO; @@ -27,6 +27,7 @@ using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using ICSharpCode.ILSpy.AppEnv;
@ -46,8 +47,8 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -46,8 +47,8 @@ namespace ICSharpCode.ILSpy.Bookmarks
/// 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.
/// bookmark's name/enabled state changes so the gutter margin can redraw. Updates reload the
/// JSON file under a mutex so multiple ILSpy instances do not overwrite unrelated edits.
/// </summary>
[Export]
[Shared]
@ -55,6 +56,7 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -55,6 +56,7 @@ namespace ICSharpCode.ILSpy.Bookmarks
{
const string FileName = "ILSpy.Bookmarks.json";
const int CurrentVersion = 1;
const string BookmarksFileMutex = "81FC41D7-A7FA-4386-B8DE-E75BA5355A35";
static readonly JsonSerializerOptions JsonOptions = new() {
WriteIndented = true,
@ -83,21 +85,29 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -83,21 +85,29 @@ namespace ICSharpCode.ILSpy.Bookmarks
public bool Toggle(Bookmark bookmark)
{
ArgumentNullException.ThrowIfNull(bookmark);
var existing = Bookmarks.FirstOrDefault(b => b.AnchorKey == bookmark.AnchorKey);
bool added = false;
UpdateSavedBookmarks(bookmarks => {
var existing = bookmarks.FirstOrDefault(b => b.AnchorKey == bookmark.AnchorKey);
if (existing != null)
{
Bookmarks.Remove(existing);
return false;
bookmarks.Remove(existing);
return;
}
if (string.IsNullOrEmpty(bookmark.Name))
bookmark.Name = NextDefaultName();
Bookmarks.Add(bookmark);
return true;
bookmark.Name = NextDefaultName(bookmarks);
bookmarks.Add(bookmark);
added = true;
});
return added;
}
public void Remove(Bookmark bookmark) => Bookmarks.Remove(bookmark);
public void Remove(Bookmark bookmark)
{
ArgumentNullException.ThrowIfNull(bookmark);
UpdateSavedBookmarks(bookmarks => bookmarks.RemoveAll(b => b.AnchorKey == bookmark.AnchorKey));
}
public void Clear() => Bookmarks.Clear();
public void Clear() => UpdateSavedBookmarks(bookmarks => bookmarks.Clear());
/// <summary>Writes the current list to <paramref name="path"/> in the on-disk JSON format.</summary>
public void Export(string path)
@ -118,20 +128,23 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -118,20 +128,23 @@ namespace ICSharpCode.ILSpy.Bookmarks
if (imported.Count == 0 && mode == BookmarkImportMode.Merge)
return;
RunBatch(() => {
UpdateSavedBookmarks(bookmarks => {
if (mode == BookmarkImportMode.Replace)
Bookmarks.Clear();
var present = new HashSet<string>(Bookmarks.Select(b => b.AnchorKey));
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);
bookmarks.Add(b);
}
});
}
/// <summary>Persists the current list to the standard sidecar location.</summary>
public void Save() => WriteTo(ConfigurationFiles.GetPath(FileName));
public void Save() => UpdateSavedBookmarks(bookmarks => {
bookmarks.Clear();
bookmarks.AddRange(Bookmarks);
});
void Load()
{
@ -150,25 +163,28 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -150,25 +163,28 @@ namespace ICSharpCode.ILSpy.Bookmarks
}
}
// Applies a multi-step mutation without persisting/raising per step, then persists once.
void RunBatch(Action action)
// Replaces the observable collection without treating each item as a user edit.
void ReplaceLiveBookmarks(List<Bookmark> bookmarks)
{
suppressPersist = true;
try
{
action();
Bookmarks.Clear();
foreach (var bookmark in bookmarks)
Bookmarks.Add(bookmark);
}
finally
{
suppressPersist = false;
}
PersistAndNotify();
}
string NextDefaultName()
string NextDefaultName() => NextDefaultName(Bookmarks);
static string NextDefaultName(IEnumerable<Bookmark> bookmarks)
{
// Pick the lowest unused "Bookmark{n}" so names stay stable and unsurprising.
var used = new HashSet<string>(Bookmarks.Select(b => b.Name));
var used = new HashSet<string>(bookmarks.Select(b => b.Name));
for (int i = 0; ; i++)
{
var candidate = "Bookmark" + i;
@ -192,7 +208,12 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -192,7 +208,12 @@ namespace ICSharpCode.ILSpy.Bookmarks
PersistAndNotify();
}
void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e) => PersistAndNotify();
void OnBookmarkPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (sender is Bookmark bookmark
&& (e.PropertyName == nameof(Bookmark.Name) || e.PropertyName == nameof(Bookmark.Enabled)))
PersistBookmarkEdit(bookmark);
}
void PersistAndNotify()
{
@ -202,16 +223,46 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -202,16 +223,46 @@ namespace ICSharpCode.ILSpy.Bookmarks
Changed?.Invoke(this, EventArgs.Empty);
}
void PersistBookmarkEdit(Bookmark bookmark)
{
if (suppressPersist)
return;
UpdateSavedBookmarks(bookmarks => {
int index = bookmarks.FindIndex(b => b.AnchorKey == bookmark.AnchorKey);
if (index >= 0)
bookmarks[index] = bookmark;
else
bookmarks.Add(bookmark);
});
}
void UpdateSavedBookmarks(Action<List<Bookmark>> update)
{
ArgumentNullException.ThrowIfNull(update);
try
{
var path = ConfigurationFiles.GetPath(FileName);
List<Bookmark> bookmarks;
using (new MutexProtector(BookmarksFileMutex))
{
bookmarks = ReadFrom(path);
update(bookmarks);
WriteListTo(path, bookmarks);
}
ReplaceLiveBookmarks(bookmarks);
Changed?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
Debug.WriteLine($"[BookmarkManager] Update failed: {ex}");
}
}
void WriteTo(string path)
{
try
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var file = new BookmarkFile(CurrentVersion, Bookmarks.Select(BookmarkRecord.From).ToList());
using var stream = System.IO.File.Create(path);
JsonSerializer.Serialize(stream, file, JsonOptions);
WriteListTo(path, Bookmarks);
}
catch (Exception ex)
{
@ -219,6 +270,16 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -219,6 +270,16 @@ namespace ICSharpCode.ILSpy.Bookmarks
}
}
static void WriteListTo(string path, IEnumerable<Bookmark> bookmarks)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var file = new BookmarkFile(CurrentVersion, bookmarks.Select(BookmarkRecord.From).ToList());
using var stream = System.IO.File.Create(path);
JsonSerializer.Serialize(stream, file, JsonOptions);
}
static List<Bookmark> ReadFrom(string path)
{
if (!System.IO.File.Exists(path))
@ -246,10 +307,12 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -246,10 +307,12 @@ namespace ICSharpCode.ILSpy.Bookmarks
sealed record BookmarkRecord(
string Name, bool Enabled, string FileName, string AssemblyFullName,
string ModuleName, uint Token, BookmarkKind Kind, int ILOffset, string MemberName)
string ModuleName, uint Token, BookmarkKind Kind, int ILOffset, int LineNumber,
string MemberName, string? LocationNodeName, BookmarkViewState? ViewState)
{
public static BookmarkRecord From(Bookmark b) => new(
b.Name, b.Enabled, b.FileName, b.AssemblyFullName, b.ModuleName, b.Token, b.Kind, b.ILOffset, b.MemberName);
b.Name, b.Enabled, b.FileName, b.AssemblyFullName, b.ModuleName, b.Token, b.Kind,
b.ILOffset, b.LineNumber, b.MemberName, b.LocationNodeName, b.ViewState);
public Bookmark ToBookmark() => new() {
Name = Name,
@ -260,8 +323,37 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -260,8 +323,37 @@ namespace ICSharpCode.ILSpy.Bookmarks
Token = Token,
Kind = Kind,
ILOffset = ILOffset,
LineNumber = LineNumber,
MemberName = MemberName,
LocationNodeName = LocationNodeName,
ViewState = ViewState,
};
}
sealed class MutexProtector : IDisposable
{
readonly Mutex mutex;
public MutexProtector(string name)
{
mutex = new Mutex(true, name, out bool createdNew);
if (createdNew)
return;
try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
}
}
public void Dispose()
{
mutex.ReleaseMutex();
mutex.Dispose();
}
}
}
}

115
ILSpy/Bookmarks/BookmarkMargin.cs

@ -21,6 +21,7 @@ using System.Collections.Generic; @@ -21,6 +21,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Threading;
@ -39,40 +40,65 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -39,40 +40,65 @@ namespace ICSharpCode.ILSpy.Bookmarks
public sealed class BookmarkMargin : AbstractMargin
{
const double IconSize = 16;
static readonly IBrush FallbackBackground = new SolidColorBrush(Color.FromRgb(0xF3, 0xF0, 0xD0));
static readonly IBrush FallbackBorder = new SolidColorBrush(Color.FromRgb(0xC8, 0xCD, 0xD3));
// One scale-up-and-back bounce, peaking at 1 + PulseAmount halfway through PulseDurationMs.
const double PulseAmount = 0.35;
const int PulseDurationMs = 600;
readonly TextView.DecompilerTextView owner;
readonly BookmarkManager? manager;
BookmarkManager? manager;
readonly DispatcherTimer pulseTimer;
readonly Stopwatch pulseElapsed = new();
bool isAttached;
bool subscribedToManager;
int pulseLine = -1;
int hoverLine = -1;
public BookmarkMargin(TextView.DecompilerTextView owner)
{
this.owner = owner;
manager = AppEnv.AppComposition.TryGetExport<BookmarkManager>();
pulseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(16) };
pulseTimer.Tick += OnPulseTick;
}
BookmarkManager? Manager {
get {
manager ??= AppEnv.AppComposition.TryGetExport<BookmarkManager>();
EnsureManagerSubscription();
return manager;
}
}
void EnsureManagerSubscription()
{
if (!isAttached || subscribedToManager || manager == null)
return;
manager.Changed += OnBookmarksChanged;
subscribedToManager = true;
}
// The manager is a shared singleton, so its Changed event would otherwise keep a closed tab's
// margin (and its pulse timer) alive. Track the subscription to the margin's time in the tree.
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (manager != null)
manager.Changed += OnBookmarksChanged;
isAttached = true;
_ = Manager;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (manager != null)
isAttached = false;
if (manager != null && subscribedToManager)
{
manager.Changed -= OnBookmarksChanged;
subscribedToManager = false;
}
pulseTimer.Stop();
pulseLine = -1;
hoverLine = -1;
}
void OnBookmarksChanged(object? sender, EventArgs e) => InvalidateVisual();
@ -107,7 +133,10 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -107,7 +133,10 @@ namespace ICSharpCode.ILSpy.Bookmarks
public override void Render(DrawingContext drawingContext)
{
DrawBackground(drawingContext);
var textView = TextView;
var manager = Manager;
if (manager == null || textView == null || !textView.VisualLinesValid)
return;
@ -116,19 +145,32 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -116,19 +145,32 @@ namespace ICSharpCode.ILSpy.Bookmarks
var glyphByLine = new Dictionary<int, IImage>();
foreach (var bookmark in manager.Bookmarks)
{
if (owner.GetLineForBookmark(bookmark) is { } line)
if (owner.GetLineForBookmark(bookmark, updateRenderedLine: false) is { } line)
{
if (bookmark.LineNumber != line)
{
Dispatcher.UIThread.Post(() => bookmark.UpdateRenderedLineNumber(line), DispatcherPriority.Background);
}
glyphByLine[line] = bookmark.Enabled ? Images.Bookmark : Images.BookmarkDisable;
}
if (glyphByLine.Count == 0)
return;
}
foreach (var visualLine in textView.VisualLines)
{
int lineNumber = visualLine.FirstDocumentLine.LineNumber;
if (!glyphByLine.TryGetValue(lineNumber, out var glyph))
bool hasGlyph = glyphByLine.TryGetValue(lineNumber, out var glyph);
bool isHoverPreview = !hasGlyph && lineNumber == hoverLine;
if (!hasGlyph && !isHoverPreview)
continue;
double top = visualLine.GetTextLineVisualYPosition(visualLine.TextLines[0], VisualYPosition.TextTop) - textView.VerticalOffset;
var rect = new Rect(0, top, IconSize, IconSize);
glyph ??= Images.Bookmark;
if (isHoverPreview)
{
using (drawingContext.PushOpacity(0.35))
drawingContext.DrawImage(glyph, rect);
continue;
}
double scale = lineNumber == pulseLine ? CurrentPulseScale() : 1.0;
if (scale != 1.0)
@ -146,18 +188,71 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -146,18 +188,71 @@ namespace ICSharpCode.ILSpy.Bookmarks
}
}
void DrawBackground(DrawingContext drawingContext)
{
// The filled background keeps the empty gutter hit-testable so the first click can
// create a bookmark and so hover previews work before any glyph has been drawn.
var bounds = new Rect(Bounds.Size);
drawingContext.DrawRectangle(GetBrush("ILSpy.BookmarkGutterBackground", FallbackBackground), null, bounds);
var border = GetBrush("ILSpy.BookmarkGutterBorder", FallbackBorder);
double x = Math.Max(0, Bounds.Width - 0.5);
drawingContext.DrawLine(new Pen(border, 1), new Point(x, 0), new Point(x, Bounds.Height));
}
IBrush GetBrush(string key, IBrush fallback)
{
return this.TryFindResource(key, ActualThemeVariant, out var resource) && resource is IBrush brush
? brush
: fallback;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
var textView = TextView;
if (e.Handled || textView == null)
return;
// Only the left button toggles. A right- or middle-click in the gutter must not
// add or remove a bookmark: it makes accidental deletion easy and would clash with
// a future gutter context menu.
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
return;
double y = e.GetPosition(textView).Y + textView.VerticalOffset;
var visualLine = textView.GetVisualLineFromVisualTop(y);
if (visualLine == null)
return;
owner.ToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber);
InvalidateVisual();
e.Handled = true;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
var textView = TextView;
int newHoverLine = -1;
if (textView != null)
{
double y = e.GetPosition(textView).Y + textView.VerticalOffset;
var visualLine = textView.GetVisualLineFromVisualTop(y);
if (visualLine != null && owner.CanToggleBookmarkAtLine(visualLine.FirstDocumentLine.LineNumber))
newHoverLine = visualLine.FirstDocumentLine.LineNumber;
}
SetHoverLine(newHoverLine);
}
protected override void OnPointerExited(PointerEventArgs e)
{
base.OnPointerExited(e);
SetHoverLine(-1);
}
void SetHoverLine(int line)
{
if (hoverLine == line)
return;
hoverLine = line;
InvalidateVisual();
}
}
}

43
ILSpy/Bookmarks/BookmarkNavigator.cs

@ -18,10 +18,9 @@ @@ -18,10 +18,9 @@
using System.Composition;
using System.IO;
using System.Reflection.Metadata.Ecma335;
using System.Linq;
using System.Threading.Tasks;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Docking;
@ -30,9 +29,9 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -30,9 +29,9 @@ 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.
/// disk if it dropped out of the list), restores the captured tree node, and asks the text view
/// to scroll to the exact line once the document is shown. A bookmark whose assembly is gone
/// offers to remove itself.
/// </summary>
[Export]
[Shared]
@ -64,35 +63,16 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -64,35 +63,16 @@ namespace ICSharpCode.ILSpy.Bookmarks
loaded = list.OpenAssembly(bookmark.FileName);
}
if (await loaded.GetMetadataFileOrNullAsync() == null
|| loaded.GetTypeSystemOrNull()?.MainModule is not MetadataModule mainModule)
if (await loaded.GetMetadataFileOrNullAsync() == null)
{
await OfferRemoveAsync(bookmark);
return;
}
IEntity? entity = null;
try
{
entity = mainModule.ResolveEntity(MetadataTokens.EntityHandle((int)bookmark.Token));
}
catch
{
// A malformed token throws inside the resolver; treated as "not found" below.
}
// The assembly is present, but the token no longer resolves -- e.g. the file on disk was
// rebuilt and the tokens shifted. That is not the "assembly missing" case, so abort
// quietly instead of nagging with the removal dialog.
if (entity == null)
return;
// Find the nearest navigable tree node: the entity itself, or the closest enclosing type
// that has one. Compiler-generated members (local functions, lambdas, their display
// classes) have no node of their own, so walk up to the user-visible type containing them.
var node = assemblyTree.FindTreeNode(entity);
for (var type = entity.DeclaringTypeDefinition; node == null && type != null; type = type.DeclaringTypeDefinition)
node = assemblyTree.FindTreeNode(type);
// Restore the selected tree node captured with the bookmark when it is still present.
var node = bookmark.ViewState?.SelectedTreeNodePath is { Count: > 0 } path
? assemblyTree.FindNodeByPath(path.ToArray(), returnBestMatch: false)
: null;
if (node == null)
return;
@ -100,6 +80,11 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -100,6 +80,11 @@ namespace ICSharpCode.ILSpy.Bookmarks
// document (and its IL-offset map) has landed.
if (AppComposition.TryGetExport<DockWorkspace>()?.ActiveDecompilerTab is { } tab)
tab.PendingBookmark = bookmark;
if (bookmark.ViewState?.RenderedLayoutSettings is { } layoutSettings
&& AppComposition.TryGetExport<SettingsService>() is { } settingsService)
{
layoutSettings.ApplyTo(settingsService.DisplaySettings);
}
assemblyTree.SelectNode(node);
}

100
ILSpy/Bookmarks/BookmarkViewState.cs

@ -0,0 +1,100 @@ @@ -0,0 +1,100 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Collections.Generic;
using System.Linq;
using ICSharpCode.ILSpy.Options;
using ICSharpCode.ILSpy.TextView;
namespace ICSharpCode.ILSpy.Bookmarks
{
/// <summary>A serializable folding range stored inside a bookmark view-state payload.</summary>
public sealed record BookmarkFoldingRange(int Start, int End);
/// <summary>Display settings that affect the rendered C# text layout.</summary>
public sealed record BookmarkRenderedLayoutSettings(
bool FoldBraces,
bool ExpandMemberDefinitions,
bool ExpandUsingDeclarations,
bool ShowDebugInfo,
bool IndentationUseTabs,
int IndentationSize,
int IndentationTabSize)
{
public static BookmarkRenderedLayoutSettings From(DisplaySettings display)
=> new(
display.FoldBraces,
display.ExpandMemberDefinitions,
display.ExpandUsingDeclarations,
display.ShowDebugInfo,
display.IndentationUseTabs,
display.IndentationSize,
display.IndentationTabSize);
public void ApplyTo(DisplaySettings display)
{
display.FoldBraces = FoldBraces;
display.ExpandMemberDefinitions = ExpandMemberDefinitions;
display.ExpandUsingDeclarations = ExpandUsingDeclarations;
display.ShowDebugInfo = ShowDebugInfo;
display.IndentationUseTabs = IndentationUseTabs;
display.IndentationSize = IndentationSize;
display.IndentationTabSize = IndentationTabSize;
}
}
/// <summary>
/// Serializable editor view state captured with a bookmark. The version is part of the payload so
/// future schema changes can be handled without changing the bookmark file version.
/// </summary>
public sealed record BookmarkViewState(
int Version,
int CaretOffset,
double VerticalOffset,
double HorizontalOffset,
int? FoldingChecksum,
IReadOnlyList<BookmarkFoldingRange>? ExpandedFoldings,
BookmarkRenderedLayoutSettings? RenderedLayoutSettings = null,
IReadOnlyList<string>? SelectedTreeNodePath = null)
{
public static BookmarkViewState From(DecompilerTextViewState state, DisplaySettings? displaySettings = null,
IReadOnlyList<string>? selectedTreeNodePath = null)
=> new(
1,
state.CaretOffset,
state.VerticalOffset,
state.HorizontalOffset,
state.Foldings?.Checksum,
state.Foldings?.Expanded.Select(r => new BookmarkFoldingRange(r.Start, r.End)).ToArray(),
displaySettings != null ? BookmarkRenderedLayoutSettings.From(displaySettings) : null,
selectedTreeNodePath);
public DecompilerTextViewState ToDecompilerTextViewState()
{
FoldingsViewState.Snapshot? foldings = null;
if (FoldingChecksum is { } checksum)
{
var expanded = ExpandedFoldings?.Select(r => (r.Start, r.End)).ToArray()
?? System.Array.Empty<(int Start, int End)>();
foldings = new FoldingsViewState.Snapshot(expanded, checksum);
}
return new DecompilerTextViewState(CaretOffset, VerticalOffset, HorizontalOffset, foldings);
}
}
}

91
ILSpy/Bookmarks/BookmarksPane.axaml

@ -5,61 +5,86 @@ @@ -5,61 +5,86 @@
xmlns:bookmarks="using:ICSharpCode.ILSpy.Bookmarks"
xmlns:res="using:ICSharpCode.ILSpy.Properties"
xmlns:ilspy="using:ICSharpCode.ILSpy"
xmlns:controls="using:ICSharpCode.ILSpy.Views.Controls"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="200"
x:Class="ICSharpCode.ILSpy.Bookmarks.BookmarksPane"
x:DataType="bookmarks:BookmarksPaneModel">
<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">
<UserControl.Styles>
<Style Selector="StackPanel#ToolbarRoot > Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="4" />
<Setter Property="Padding" Value="4,2" />
<Setter Property="MinHeight" Value="22" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Button > Image">
<Setter Property="Width" Value="16" />
<Setter Property="Height" Value="16" />
<Style Selector="StackPanel#ToolbarRoot > Button /template/ ContentPresenter">
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="CornerRadius" Value="2" />
</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}" />
<Style Selector="StackPanel#ToolbarRoot > Button:not(:disabled):pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#330078D7" />
<Setter Property="BorderBrush" Value="#FF0078D7" />
</Style>
<Style Selector="StackPanel#ToolbarRoot > Button:not(:disabled):pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#660078D7" />
<Setter Property="BorderBrush" Value="#FF005A9E" />
</Style>
<Style Selector="StackPanel#ToolbarRoot > Button:disabled">
<Setter Property="Cursor" Value="Arrow" />
</Style>
<Style Selector="StackPanel#ToolbarRoot > Button:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ILSpy.ToolbarDisabledFill}" />
<Setter Property="BorderBrush" Value="{DynamicResource ILSpy.ToolbarDisabledBorder}" />
</Style>
<Style Selector="StackPanel#ToolbarRoot > Separator">
<Setter Property="Width" Value="1" />
<Setter Property="Height" Value="18" />
<Setter Property="Margin" Value="4,3" />
<Setter Property="Background" Value="{DynamicResource ILSpy.ToolbarSeparator}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<!-- Toolbar: bookmark actions. Each button shows a 16x16 glyph and a tooltip; commands live
on the pane view model. -->
<Border Grid.Row="0" Name="ToolbarBorder" BorderThickness="0,0,0,1" BorderBrush="{DynamicResource ILSpy.ToolbarBorder}"
MinHeight="29" Padding="3" Background="{DynamicResource ILSpy.ToolbarBackground}">
<StackPanel Name="ToolbarRoot" Orientation="Horizontal">
<Button Command="{Binding PreviousCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkPreviousBookmark}">
<Image Source="{x:Static ilspy:Images.BookmarkPrevious}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.BookmarkPrevious}" Width="16" Height="16" />
</Button>
<Button Command="{Binding NextCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkNextBookmark}">
<Image Source="{x:Static ilspy:Images.BookmarkNext}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.BookmarkNext}" Width="16" Height="16" />
</Button>
<Button Command="{Binding PreviousInFileCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkPreviousInFile}">
<Image Source="{x:Static ilspy:Images.BookmarkPreviousInFile}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.BookmarkPreviousInFile}" Width="16" Height="16" />
</Button>
<Button Command="{Binding NextInFileCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkNextInFile}">
<Image Source="{x:Static ilspy:Images.BookmarkNextInFile}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.BookmarkNextInFile}" Width="16" Height="16" />
</Button>
<Border Width="1" Margin="2,2" Background="{DynamicResource ILSpy.ToolbarSeparatorBrush}" />
<Separator />
<Button Command="{Binding DisableCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkDisable}">
<Image Source="{x:Static ilspy:Images.BookmarkDisable}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.BookmarkDisable}" Width="16" Height="16" />
</Button>
<Button Command="{Binding DeleteCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkDelete}">
<Image Source="{x:Static ilspy:Images.BookmarkClear}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.BookmarkClear}" Width="16" Height="16" />
</Button>
<Border Width="1" Margin="2,2" Background="{DynamicResource ILSpy.ToolbarSeparatorBrush}" />
<Separator />
<Button Command="{Binding ExportCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkExport}">
<Image Source="{x:Static ilspy:Images.Save}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.Save}" Width="16" Height="16" />
</Button>
<Button Command="{Binding ImportCommand}" ToolTip.Tip="{x:Static res:Resources.BookmarkImport}">
<Image Source="{x:Static ilspy:Images.Open}" />
<controls:GrayscaleAwareImage Source="{x:Static ilspy:Images.Open}" Width="16" Height="16" />
</Button>
</StackPanel>
</Border>
<!-- 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"
<DataGrid Grid.Row="1" Name="BookmarkGrid" Margin="4"
ItemsSource="{Binding Bookmarks}"
SelectedItem="{Binding SelectedBookmark, Mode=TwoWay}"
AutoGenerateColumns="False"
@ -96,9 +121,17 @@ @@ -96,9 +121,17 @@
</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" />
Binding="{Binding DisplayLocation}" IsReadOnly="True" />
<DataGridTemplateColumn Header="{x:Static res:Resources.BookmarkModule}" Width="2*" IsReadOnly="True">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate DataType="bookmarks:Bookmark">
<TextBlock Text="{Binding ModuleName}"
ToolTip.Tip="{Binding FileName}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>

3
ILSpy/Bookmarks/BookmarksPaneModel.cs

@ -71,9 +71,6 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -71,9 +71,6 @@ namespace ICSharpCode.ILSpy.Bookmarks
return navigator.NavigateToAsync(bookmark);
}
[RelayCommand]
void Toggle() => ActiveTab?.ToggleBookmarkAtCaret?.Invoke();
[RelayCommand]
void Next() => NavigateRelative(1);

12
ILSpy/Bookmarks/ToggleBookmarkContextMenuEntry.cs

@ -24,17 +24,23 @@ namespace ICSharpCode.ILSpy.Bookmarks @@ -24,17 +24,23 @@ 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.
/// hold a bookmark; the text view decides eligibility from the right-clicked text location.
/// </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;
=> context.TextView is { } view
&& context.TextLocation is { } offset
&& view.CanToggleBookmarkAtOffset(offset);
public bool IsEnabled(TextViewContext context) => true;
public void Execute(TextViewContext context) => context.TextView?.ToggleBookmarkAtRightClick();
public void Execute(TextViewContext context)
{
if (context.TextView is { } view && context.TextLocation is { } offset)
view.ToggleBookmarkAtOffset(offset);
}
}
}

2
ILSpy/Images.cs

@ -87,7 +87,7 @@ namespace ICSharpCode.ILSpy @@ -87,7 +87,7 @@ namespace ICSharpCode.ILSpy
public static readonly IImage ShowAll = LoadSvg(nameof(ShowAll));
// Bookmarks (file names carry dots, so they can't use nameof; "Boomark.Disable" is the
// asset's actual — misspelled — file name).
// asset's actual -- misspelled -- file name).
public static readonly IImage Bookmark = LoadSvg(nameof(Bookmark));
public static readonly IImage BookmarkDisable = LoadSvg("Boomark.Disable");
public static readonly IImage BookmarkNext = LoadSvg("Bookmark.Next");

3
ILSpy/TextView/DecompilerTabPageModel.cs

@ -245,9 +245,6 @@ namespace ICSharpCode.ILSpy.TextView @@ -245,9 +245,6 @@ namespace ICSharpCode.ILSpy.TextView
[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; }

148
ILSpy/TextView/DecompilerTextView.axaml.cs

@ -33,6 +33,7 @@ using Avalonia.Media; @@ -33,6 +33,7 @@ using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit.Document;
using AvaloniaEdit.Folding;
using AvaloniaEdit.Highlighting;
@ -43,10 +44,13 @@ using ICSharpCode.Decompiler.Metadata; @@ -43,10 +44,13 @@ using ICSharpCode.Decompiler.Metadata;
using ICSharpCode.Decompiler.Output;
using ICSharpCode.Decompiler.TypeSystem;
using ICSharpCode.ILSpyX;
using ICSharpCode.ILSpyX.TreeView;
using ICSharpCode.ILSpy;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Options;
using ICSharpCode.ILSpy.TreeNodes;
namespace ICSharpCode.ILSpy.TextView
{
@ -93,7 +97,6 @@ namespace ICSharpCode.ILSpy.TextView @@ -93,7 +97,6 @@ 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!;
@ -616,7 +619,6 @@ namespace ICSharpCode.ILSpy.TextView @@ -616,7 +619,6 @@ 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;
@ -673,12 +675,31 @@ namespace ICSharpCode.ILSpy.TextView @@ -673,12 +675,31 @@ namespace ICSharpCode.ILSpy.TextView
{
if (!ShowsBookmarkableCode || DataContext is not DecompilerTabPageModel model)
return null;
return Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line);
var fallbackOwner = model.CurrentNode is IMemberTreeNode memberNode ? memberNode.Member : null;
var bookmark = Bookmarks.BookmarkAnchoring.CreateForLine(model.DebugInfo, model.References, Editor.Document, line,
fallbackOwner, GetLocationNodeName(model.CurrentNode));
if (bookmark != null)
{
var displaySettings = AppComposition.TryGetExport<SettingsService>()?.DisplaySettings;
var selectedTreeNodePath = AssemblyTreeModel.GetPathForNode(model.CurrentNode);
bookmark.ViewState = Bookmarks.BookmarkViewState.From(GetCurrentViewState(), displaySettings, selectedTreeNodePath);
}
return bookmark;
}
/// <summary>Whether <paramref name="line"/> is a statement or definition line that can hold a bookmark.</summary>
static string? GetLocationNodeName(SharpTreeNode? node)
=> node is IMemberTreeNode { Member: { } member } ? member.FullName : node?.ToString();
/// <summary>Whether <paramref name="line"/> can hold a bookmark in the current document.</summary>
internal bool CanToggleBookmarkAtLine(int line) => CreateBookmarkForLine(line) != null;
internal bool CanToggleBookmarkAtOffset(int offset)
{
if (offset < 0 || offset > Editor.Document.TextLength)
return false;
return CanToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber);
}
/// <summary>Adds or removes a bookmark on <paramref name="line"/>; a no-op for non-anchorable lines.</summary>
internal void ToggleBookmarkAtLine(int line)
{
@ -686,14 +707,11 @@ namespace ICSharpCode.ILSpy.TextView @@ -686,14 +707,11 @@ namespace ICSharpCode.ILSpy.TextView
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()
internal void ToggleBookmarkAtOffset(int offset)
{
if (lastRightClickedLine >= 1)
ToggleBookmarkAtLine(lastRightClickedLine);
if (offset < 0 || offset > Editor.Document.TextLength)
return;
ToggleBookmarkAtLine(Editor.Document.GetLineByOffset(offset).LineNumber);
}
void OnBookmarkKeyDown(object? sender, KeyEventArgs e)
@ -705,9 +723,6 @@ namespace ICSharpCode.ILSpy.TextView @@ -705,9 +723,6 @@ namespace ICSharpCode.ILSpy.TextView
}
}
// Wired onto the model so the bookmarks-pane toolbar can act on the active document.
void ToggleBookmarkAtCaret() => ToggleBookmarkAtLine(Editor.TextArea.Caret.Line);
// Moves the caret to the next/previous bookmark in this document, ordered by line and relative
// to the caret (wrapping around). A no-op when the document holds fewer than one bookmark.
void NavigateBookmarkInFile(bool forward)
@ -737,32 +752,74 @@ namespace ICSharpCode.ILSpy.TextView @@ -737,32 +752,74 @@ namespace ICSharpCode.ILSpy.TextView
return;
if (GetLineForBookmark(bookmark) is { } line)
{
ScrollToLine(line);
ScrollToLine(line, bookmark.ViewState);
model.PendingBookmark = null;
}
}
void ScrollToLine(int line)
void ScrollToLine(int line, Bookmarks.BookmarkViewState? viewState = null)
{
line = Math.Clamp(line, 1, Editor.Document.LineCount);
Editor.TextArea.Caret.Offset = Editor.Document.GetLineByNumber(line).Offset;
var document = Editor.Document;
line = Math.Clamp(line, 1, document.LineCount);
Editor.TextArea.Caret.Offset = document.GetLineByNumber(line).Offset;
// Centre the line and pulse its gutter icon once the layout has caught up. Posting lets a
// just-applied document finish measuring, so the visual position is accurate either way --
// whether we got here after a fresh decompile or while the document was already on screen.
Dispatcher.UIThread.Post(() => {
CenterLineInView(line);
if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount)
return;
CenterLineInView(document, line);
LineHighlightAdorner.DisplayLineHighlight(Editor.TextArea, line);
bookmarkMargin?.PulseLine(line);
if (viewState != null)
RestoreBookmarkViewState(viewState);
}, DispatcherPriority.Background);
}
void RestoreBookmarkViewState(Bookmarks.BookmarkViewState viewState)
{
var state = viewState.ToDecompilerTextViewState();
RestoreCurrentFoldings(state.Foldings);
Editor.TextArea.Caret.Offset = Math.Clamp(state.CaretOffset, 0, Editor.Document.TextLength);
if (EditorScrollViewer is { } scrollViewer)
scrollViewer.Offset = new Vector(state.HorizontalOffset, state.VerticalOffset);
}
void RestoreCurrentFoldings(FoldingsViewState.Snapshot? saved)
{
if (saved == null || activeFoldingManager is not { } manager)
return;
var current = FoldingsViewState.Capture(manager.AllFoldings);
if (current.Checksum != saved.Value.Checksum)
return;
foreach (var folding in manager.AllFoldings)
{
bool wasExpanded = false;
foreach (var (start, end) in saved.Value.Expanded)
{
if (start == folding.StartOffset && end == folding.EndOffset)
{
wasExpanded = true;
break;
}
}
folding.IsFolded = !wasExpanded;
}
}
// Scrolls so <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)
void CenterLineInView(TextDocument document, int line)
{
if (EditorScrollViewer is not { } scrollViewer)
return;
if (!ReferenceEquals(Editor.Document, document) || line < 1 || line > document.LineCount)
return;
var textView = Editor.TextArea.TextView;
if (!ReferenceEquals(textView.Document, document))
return;
double visualTop = textView.GetVisualTopByDocumentLine(line);
double target = visualTop - (scrollViewer.Viewport.Height - textView.DefaultLineHeight) / 2;
scrollViewer.Offset = new Vector(scrollViewer.Offset.X, Math.Max(0, target));
@ -774,7 +831,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -774,7 +831,7 @@ namespace ICSharpCode.ILSpy.TextView
/// 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)
internal int? GetLineForBookmark(Bookmarks.Bookmark bookmark, bool updateRenderedLine = true)
{
if (DataContext is not DecompilerTabPageModel { SyntaxExtension: ".cs" } model)
return null;
@ -787,8 +844,21 @@ namespace ICSharpCode.ILSpy.TextView @@ -787,8 +844,21 @@ namespace ICSharpCode.ILSpy.TextView
{
if (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName
&& method.TryGetLineForOffset(bookmark.ILOffset, out var bodyLine))
{
if (updateRenderedLine)
bookmark.UpdateRenderedLineNumber(bodyLine);
return bodyLine;
}
}
return null;
}
if (bookmark.Kind == Bookmarks.BookmarkKind.Line)
{
if (bookmark.LineNumber < 1 || bookmark.LineNumber > Editor.Document.LineCount)
return null;
if (DocumentContainsBookmarkToken(model, bookmark))
return bookmark.LineNumber;
return null;
}
@ -799,12 +869,42 @@ namespace ICSharpCode.ILSpy.TextView @@ -799,12 +869,42 @@ namespace ICSharpCode.ILSpy.TextView
if (segment.IsDefinition && segment.Reference is IEntity entity
&& (uint)System.Reflection.Metadata.Ecma335.MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token
&& entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName)
return Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber;
{
var line = Editor.Document.GetLineByOffset(segment.StartOffset).LineNumber;
if (updateRenderedLine)
bookmark.UpdateRenderedLineNumber(line);
return line;
}
}
}
return null;
}
static bool DocumentContainsBookmarkToken(DecompilerTabPageModel model, Bookmarks.Bookmark bookmark)
{
if (model.DebugInfo != null)
{
foreach (var method in model.DebugInfo.Methods)
{
if (method.Token == bookmark.Token && method.AssemblyFullName == bookmark.AssemblyFullName)
return true;
}
}
if (model.References != null)
{
foreach (var segment in model.References)
{
if (segment.Reference is IEntity entity
&& (uint)MetadataTokens.GetToken(entity.MetadataToken) == bookmark.Token
&& entity.ParentModule?.MetadataFile?.FullName == bookmark.AssemblyFullName)
return true;
}
}
return false;
}
#endregion
protected override void OnDataContextChanged(System.EventArgs e)
@ -817,7 +917,6 @@ namespace ICSharpCode.ILSpy.TextView @@ -817,7 +917,6 @@ namespace ICSharpCode.ILSpy.TextView
{
previous.PropertyChanged -= OnModelPropertyChanged;
previous.CaptureViewState = null;
previous.ToggleBookmarkAtCaret = null;
previous.NavigateBookmarkInFile = null;
}
@ -829,8 +928,7 @@ namespace ICSharpCode.ILSpy.TextView @@ -829,8 +928,7 @@ 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;
// Bookmarks-pane toolbar navigation actions that operate on the active document route through this.
model.NavigateBookmarkInFile = NavigateBookmarkInFile;
ApplyDocument(model);
// Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the

Loading…
Cancel
Save