diff --git a/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs b/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs
index 4abb404f3c..fad106dc46 100644
--- a/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs
+++ b/ICSharpCode.ILSpyX/Settings/ILSpySettings.cs
@@ -18,7 +18,6 @@
using System;
using System.IO;
-using System.Threading;
using System.Xml;
using System.Xml.Linq;
@@ -129,34 +128,5 @@ static string GetConfigFile()
}
const string ConfigFileMutex = "01A91708-49D1-410D-B8EB-4DE2662B3971";
-
- ///
- /// Helper class for serializing access to the config file when multiple ILSpy instances are running.
- ///
- sealed class MutexProtector : IDisposable
- {
- readonly Mutex mutex;
-
- public MutexProtector(string name)
- {
- this.mutex = new Mutex(true, name, out bool createdNew);
- if (createdNew)
- return;
-
- try
- {
- mutex.WaitOne();
- }
- catch (AbandonedMutexException)
- {
- }
- }
-
- public void Dispose()
- {
- mutex.ReleaseMutex();
- mutex.Dispose();
- }
- }
}
}
diff --git a/ICSharpCode.ILSpyX/Settings/MutexProtector.cs b/ICSharpCode.ILSpyX/Settings/MutexProtector.cs
new file mode 100644
index 0000000000..2ca7aa760c
--- /dev/null
+++ b/ICSharpCode.ILSpyX/Settings/MutexProtector.cs
@@ -0,0 +1,55 @@
+// 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.Threading;
+
+namespace ICSharpCode.ILSpyX.Settings
+{
+ ///
+ /// Guards a config-file read-modify-write with a named so that two ILSpy
+ /// instances editing the same sidecar (the settings XML, the bookmark list, ...) fold their
+ /// changes in turn instead of overwriting each other. Acquire it around the
+ /// read-update-write block and dispose it to release.
+ ///
+ public sealed class MutexProtector : IDisposable
+ {
+ readonly Mutex mutex;
+
+ public MutexProtector(string name)
+ {
+ this.mutex = new Mutex(true, name, out bool createdNew);
+ if (createdNew)
+ return;
+
+ try
+ {
+ mutex.WaitOne();
+ }
+ catch (AbandonedMutexException)
+ {
+ }
+ }
+
+ public void Dispose()
+ {
+ mutex.ReleaseMutex();
+ mutex.Dispose();
+ }
+ }
+}
diff --git a/ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs b/ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs
new file mode 100644
index 0000000000..f421ec1074
--- /dev/null
+++ b/ILSpy.Tests/AppEnv/ConfigurationFilesTests.cs
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.IO;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpyX.Settings;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.AppEnv;
+
+// ConfigurationFiles.GetPath underpins where the dock layout and the bookmark list are
+// stored: as JSON sidecars in the same directory as ILSpy.xml. These tests pin that
+// "next to the settings file" contract and the headless fallback.
+[TestFixture]
+public class ConfigurationFilesTests
+{
+ Func? savedProvider;
+
+ [SetUp]
+ public void SaveProvider() => savedProvider = ILSpySettings.SettingsFilePathProvider;
+
+ [TearDown]
+ public void RestoreProvider() => ILSpySettings.SettingsFilePathProvider = savedProvider;
+
+ [Test]
+ public void GetPath_returns_sidecar_in_settings_directory()
+ {
+ var settingsDir = Path.Combine(Path.GetTempPath(), "ILSpyConfigTest");
+ ILSpySettings.SettingsFilePathProvider = () => Path.Combine(settingsDir, "ILSpy.xml");
+
+ ConfigurationFiles.GetPath("ILSpy.Bookmarks.json")
+ .Should().Be(Path.Combine(settingsDir, "ILSpy.Bookmarks.json"));
+ }
+
+ [Test]
+ public void GetPath_falls_back_to_bare_name_when_provider_is_absent()
+ {
+ ILSpySettings.SettingsFilePathProvider = null;
+
+ ConfigurationFiles.GetPath("ILSpy.Bookmarks.json").Should().Be("ILSpy.Bookmarks.json");
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs
new file mode 100644
index 0000000000..4e1a7a5aad
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkAnchoringTests.cs
@@ -0,0 +1,82 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+using System.Threading.Tasks;
+
+using AvaloniaEdit.Document;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Bookmarks;
+using ICSharpCode.ILSpy.Languages;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Bookmarks;
+
+// End-to-end anchoring against real decompiled output: a statement line yields a body anchor,
+// a definition line yields a token anchor, and a comment line yields nothing.
+[TestFixture]
+public class BookmarkAnchoringTests
+{
+ [AvaloniaTest]
+ public async Task Decompiled_type_offers_both_body_and_token_anchors()
+ {
+ var (_, vm) = await TestHarness.BootAsync(3);
+ var csharp = AppComposition.Current.GetExport().Languages.First(l => l.Name == "C#");
+
+ var asm = vm.AssemblyTreeModel.FindNode("System.Linq");
+ var typeSystem = asm!.LoadedAssembly.GetTypeSystemOrNull();
+ var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == "System.Linq.Enumerable");
+
+ var output = new AvaloniaEditTextOutput();
+ csharp.DecompileType(type, output, new DecompilationOptions(new DecompilerSettings()));
+
+ var document = new TextDocument(output.GetText());
+ var debugInfo = new DecompiledDebugInfo(output.MethodDebugInfos);
+
+ Bookmark? body = null, token = null;
+ for (int line = 1; line <= document.LineCount; line++)
+ {
+ var bookmark = BookmarkAnchoring.CreateForLine(debugInfo, output.References, document, line);
+ if (bookmark?.Kind == BookmarkKind.Body)
+ body ??= bookmark;
+ else if (bookmark?.Kind == BookmarkKind.Token)
+ token ??= bookmark;
+ }
+
+ body.Should().NotBeNull("statement lines must produce a body anchor");
+ token.Should().NotBeNull("definition lines must produce a token anchor");
+
+
+ // Line 1 is the "// " comment: neither a statement nor a definition,
+ // 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));
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs b/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs
new file mode 100644
index 0000000000..1acc4430f5
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkContextMenuTests.cs
@@ -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();
+ manager.Clear();
+
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(typeNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ 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()
+ .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();
+ manager.Clear();
+
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(objectNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ 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()
+ .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(coreLibName, "System", "System.String");
+ vm.AssemblyTreeModel.SelectNode(stringNode);
+
+ await AppComposition.Current.GetExport().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();
+ 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();
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs b/ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs
new file mode 100644
index 0000000000..8a3fcceddc
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkDebugInfoCaptureTests.cs
@@ -0,0 +1,71 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Linq;
+using System.Reflection.Metadata.Ecma335;
+using System.Threading.Tasks;
+
+using Avalonia.Headless.NUnit;
+
+using AwesomeAssertions;
+
+using ICSharpCode.Decompiler;
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Languages;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Bookmarks;
+
+// Proves the sequence-point capture in CSharpLanguage.WriteCode actually populates the
+// per-document debug map against real decompiled output -- the foundation of the body anchor.
+[TestFixture]
+public class BookmarkDebugInfoCaptureTests
+{
+ [AvaloniaTest]
+ public async Task CSharp_decompile_captures_a_method_map_that_round_trips()
+ {
+ var (_, vm) = await TestHarness.BootAsync(3);
+ var csharp = AppComposition.Current.GetExport().Languages.First(l => l.Name == "C#");
+
+ var asm = vm.AssemblyTreeModel.FindNode("System.Linq");
+ Assert.That(asm, Is.Not.Null);
+ var typeSystem = asm!.LoadedAssembly.GetTypeSystemOrNull();
+ Assert.That(typeSystem, Is.Not.Null);
+ var type = typeSystem!.MainModule.TypeDefinitions.First(t => t.FullName == "System.Linq.Enumerable");
+ var method = type.Methods.First(m => m.HasBody && !m.IsConstructor);
+
+ var output = new AvaloniaEditTextOutput();
+ csharp.DecompileMethod(method, output, new DecompilationOptions(new DecompilerSettings()));
+
+ output.MethodDebugInfos.Should().NotBeEmpty("a method with a body emits sequence points");
+
+ uint token = (uint)MetadataTokens.GetToken(method.MetadataToken);
+ var map = output.MethodDebugInfos.FirstOrDefault(m => m.Token == token);
+ map.Should().NotBeNull("the decompiled method itself must be in the map");
+
+ // The first statement (IL offset 0) maps to a real line, and that line maps back to offset 0.
+ map!.TryGetLineForOffset(0, out var line).Should().BeTrue();
+ int lineCount = output.GetText().Replace("\r\n", "\n").Split('\n').Length;
+ line.Should().BeInRange(1, lineCount);
+ map.TryGetOffsetForLine(line, out var offset).Should().BeTrue();
+ offset.Should().Be(0);
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs
new file mode 100644
index 0000000000..d73f845bce
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkGutterTests.cs
@@ -0,0 +1,278 @@
+// 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;
+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 BookmarkGutterTests
+{
+ [AvaloniaTest]
+ public async Task Clicking_Bookmark_Gutter_Toggles_The_Visible_Line()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+ var manager = AppComposition.Current.GetExport();
+ manager.Clear();
+
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(typeNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(25);
+ }
+ window.UpdateLayout();
+
+ var margin = view.Editor.TextArea.LeftMargins.OfType().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();
+ manager.Clear();
+
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(typeNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(25);
+ }
+ window.UpdateLayout();
+
+ var margin = view.Editor.TextArea.LeftMargins.OfType().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");
+ }
+
+ [AvaloniaTest]
+ public async Task Removing_A_Bookmark_From_The_Gutter_Hides_Its_Hover_Preview_Until_The_Pointer_Leaves_The_Line()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+ var manager = AppComposition.Current.GetExport();
+ manager.Clear();
+
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var typeNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(typeNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(25);
+ }
+ window.UpdateLayout();
+
+ var margin = view.Editor.TextArea.LeftMargins.OfType().Single();
+ var textView = view.Editor.TextArea.TextView;
+ textView.EnsureVisualLines();
+ var bookmarkable = textView.VisualLines
+ .Where(line => view.CanToggleBookmarkAtLine(line.FirstDocumentLine.LineNumber))
+ .Take(2)
+ .ToArray();
+ bookmarkable.Should().HaveCount(2, "two bookmarkable lines are needed to leave and re-enter the removed line");
+
+ Point GutterPoint(VisualLine line)
+ {
+ double y = line.GetTextLineVisualYPosition(line.TextLines[0], VisualYPosition.LineMiddle) - textView.VerticalOffset;
+ var p = margin.TranslatePoint(new Point(margin.Bounds.Width / 2, y), window);
+ p.Should().NotBeNull("the gutter line coordinate must map into the test window");
+ return p!.Value;
+ }
+
+ int line0 = bookmarkable[0].FirstDocumentLine.LineNumber;
+ int line1 = bookmarkable[1].FirstDocumentLine.LineNumber;
+ Point point0 = GutterPoint(bookmarkable[0]);
+ Point point1 = GutterPoint(bookmarkable[1]);
+
+ // Hovering an empty bookmarkable line previews a ghost glyph.
+ window.MouseMove(point0);
+ Dispatcher.UIThread.RunJobs();
+ margin.HoverPreviewLine.Should().Be(line0, "hovering a bookmarkable line previews its glyph");
+
+ // Click adds the bookmark; the pointer stays on the line.
+ window.MouseDown(point0, MouseButton.Left);
+ window.MouseUp(point0, MouseButton.Left);
+ Dispatcher.UIThread.RunJobs();
+ manager.Bookmarks.Should().ContainSingle();
+
+ // Clicking again removes it: the line must visibly empty rather than redraw a ghost.
+ window.MouseDown(point0, MouseButton.Left);
+ window.MouseUp(point0, MouseButton.Left);
+ Dispatcher.UIThread.RunJobs();
+ manager.Bookmarks.Should().BeEmpty();
+ margin.HoverPreviewLine.Should().Be(-1, "removing via the gutter hides the preview");
+
+ // A jitter that stays on the just-removed line must NOT bring the preview back.
+ window.MouseMove(new Point(point0.X + 1, point0.Y));
+ Dispatcher.UIThread.RunJobs();
+ margin.HoverPreviewLine.Should().Be(-1, "moving within the same line keeps the removed line's preview hidden");
+
+ // Moving onto a different line resumes normal hover.
+ window.MouseMove(point1);
+ Dispatcher.UIThread.RunJobs();
+ margin.HoverPreviewLine.Should().Be(line1, "a different line hovers normally");
+
+ // Returning to the original line now shows its preview again.
+ window.MouseMove(point0);
+ Dispatcher.UIThread.RunJobs();
+ margin.HoverPreviewLine.Should().Be(line0, "leaving and re-entering the line restores its preview");
+ }
+
+ // Regression: the editor reuses one TextDocument and only swaps its text on navigation, so the
+ // gutter's cached glyph map must rebuild off the document's content (DebugInfo/References), not off
+ // the document reference -- otherwise switching nodes leaves the gutter showing the previous (or no)
+ // document's bookmarks.
+ [AvaloniaTest]
+ public async Task Gutter_Glyph_Map_Rebuilds_When_The_Displayed_Document_Changes()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+ var manager = AppComposition.Current.GetExport();
+ manager.Clear();
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+
+ async Task Show(string typeName)
+ {
+ var node = vm.AssemblyTreeModel.FindNode(coreLibName, "System", typeName);
+ vm.AssemblyTreeModel.SelectNode(node);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var shown = await window.WaitForComponent();
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(25);
+ }
+ return shown;
+ }
+
+ var view = await Show("System.Object");
+ var margin = view.Editor.TextArea.LeftMargins.OfType().Single();
+ var toggle = AppComposition.Current.GetExport().GetEntry(nameof(Resources.BookmarkToggle));
+
+ // A body (IL-offset) anchor, so it only resolves inside System.Object -- a token/line anchor on
+ // the type would also resolve in System.String, which merely references System.Object.
+ Bookmark? bookmark = null;
+ foreach (int candidate in Enumerable.Range(1, view.Editor.Document.LineCount).Where(view.CanToggleBookmarkAtLine))
+ {
+ int candidateOffset = view.Editor.Document.GetLineByNumber(candidate).Offset;
+ view.Editor.TextArea.Caret.Offset = candidateOffset;
+ toggle.Execute(new TextViewContext { TextView = view, TextLocation = candidateOffset });
+ Dispatcher.UIThread.RunJobs();
+ var candidateBookmark = manager.Bookmarks.Single();
+ if (candidateBookmark.Kind == BookmarkKind.Body)
+ {
+ bookmark = candidateBookmark;
+ break;
+ }
+ manager.Clear();
+ }
+ bookmark.Should().NotBeNull("System.Object has a method-body line to anchor to");
+ int objectLine = view.GetLineForBookmark(bookmark!)!.Value;
+ margin.GlyphLinesForTest().Keys.Should().Contain(objectLine, "the gutter shows the bookmark on its own document");
+
+ // Navigate to another type. The preview tab reuses the same view (and gutter), only swapping the
+ // document text, so the gutter must drop the stale glyph: System.Object's bookmark is not here.
+ var stringView = await Show("System.String");
+ stringView.Should().BeSameAs(view, "the preview tab reuses the view, so this exercises the stale cache");
+ margin.GlyphLinesForTest().Should().BeEmpty("the gutter must rebuild for System.String, where the bookmark does not resolve");
+
+ // Returning to the bookmarked document must surface its glyph again.
+ await Show("System.Object");
+ margin.GlyphLinesForTest().Keys.Should().Contain(view.GetLineForBookmark(bookmark!)!.Value,
+ "returning to the bookmarked document must show its glyph again");
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs
new file mode 100644
index 0000000000..6b142338db
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkManagerTests.cs
@@ -0,0 +1,375 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System;
+using System.IO;
+using System.Linq;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.Bookmarks;
+using ICSharpCode.ILSpyX.Settings;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Bookmarks;
+
+// Exercises the manager's pure list/persistence behaviour without the decompiler: toggle,
+// default naming, JSON round-trip, and the merge-vs-replace import rules.
+[TestFixture]
+public class BookmarkManagerTests
+{
+ Func? savedProvider;
+ string tempDir = "";
+
+ [SetUp]
+ public void SetUp()
+ {
+ savedProvider = ILSpySettings.SettingsFilePathProvider;
+ tempDir = Path.Combine(Path.GetTempPath(), "ILSpyBookmarkTest_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(tempDir);
+ ILSpySettings.SettingsFilePathProvider = () => Path.Combine(tempDir, "ILSpy.xml");
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ ILSpySettings.SettingsFilePathProvider = savedProvider;
+ try
+ {
+ Directory.Delete(tempDir, recursive: true);
+ }
+ catch
+ {
+ // best effort
+ }
+ }
+
+ // A manager whose sidecar lives in its own subdirectory, so two managers in one test don't
+ // share (and preload) each other's saved list through the common settings path.
+ BookmarkManager NewManagerInFreshDir()
+ {
+ var dir = Path.Combine(tempDir, Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ ILSpySettings.SettingsFilePathProvider = () => Path.Combine(dir, "ILSpy.xml");
+ return new BookmarkManager();
+ }
+
+ static Bookmark MakeBookmark(uint token, BookmarkKind kind = BookmarkKind.Token, int ilOffset = 0, string name = "", int lineNumber = 1, string? locationNodeName = null)
+ => new() {
+ Name = name,
+ FileName = @"C:\asm\Sample.dll",
+ AssemblyFullName = "Sample, Version=1.0.0.0",
+ ModuleName = "Sample.dll",
+ Token = token,
+ Kind = kind,
+ ILOffset = ilOffset,
+ LineNumber = lineNumber,
+ MemberName = "Sample.Type.Member",
+ LocationNodeName = locationNodeName,
+ };
+
+ [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 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 Adding_a_bookmark_preserves_existing_live_instances()
+ {
+ var manager = new BookmarkManager();
+ manager.Toggle(MakeBookmark(0x06000001, name: "A"));
+ var first = manager.Bookmarks[0];
+
+ manager.Toggle(MakeBookmark(0x06000002, name: "B"));
+
+ manager.Bookmarks.Should().HaveCount(2);
+ // A structural change must reconcile the live list, not rebuild it: the bookmarks pane holds a
+ // live selection by reference, so replacing every instance on each toggle would drop it.
+ manager.Bookmarks[0].Should().BeSameAs(first, "adding a bookmark must not replace existing instances");
+ }
+
+ [Test]
+ public void Editing_a_bookmark_keeps_the_other_live_instances_intact()
+ {
+ var manager = new BookmarkManager();
+ manager.Toggle(MakeBookmark(0x06000001, name: "A"));
+ manager.Toggle(MakeBookmark(0x06000002, name: "B"));
+ var first = manager.Bookmarks[0];
+ var second = manager.Bookmarks[1];
+
+ // Editing a name (as the pane's Name editor does) must persist without tearing down the list.
+ first.Name = "A renamed";
+
+ manager.Bookmarks[0].Should().BeSameAs(first);
+ manager.Bookmarks[1].Should().BeSameAs(second, "editing one bookmark must not replace the others");
+ new BookmarkManager().Bookmarks.Single(b => b.Token == 0x06000001).Name.Should().Be("A renamed");
+ }
+
+ [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 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()
+ {
+ 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");
+ }
+
+ [Test]
+ public void Import_replace_of_a_corrupt_file_keeps_the_existing_bookmarks()
+ {
+ var corruptPath = Path.Combine(tempDir, "corrupt.json");
+ File.WriteAllText(corruptPath, "{ this is not valid json");
+
+ var target = NewManagerInFreshDir();
+ target.Toggle(MakeBookmark(0x06000001, name: "Keep"));
+
+ bool ok = target.Import(corruptPath, BookmarkImportMode.Replace);
+
+ ok.Should().BeFalse("a file that cannot be parsed is not a successful import");
+ target.Bookmarks.Select(b => b.Name).Should().Equal(new[] { "Keep" });
+ }
+
+ [Test]
+ public void Import_replace_of_a_valid_empty_file_clears_the_bookmarks()
+ {
+ var source = NewManagerInFreshDir();
+ var emptyExport = Path.Combine(tempDir, "empty.json");
+ source.Export(emptyExport);
+
+ var target = NewManagerInFreshDir();
+ target.Toggle(MakeBookmark(0x06000001, name: "Old"));
+
+ bool ok = target.Import(emptyExport, BookmarkImportMode.Replace);
+
+ ok.Should().BeTrue("a valid empty file is a successful import");
+ target.Bookmarks.Should().BeEmpty("Replace with a valid empty file legitimately clears the list");
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs
new file mode 100644
index 0000000000..84244e7efc
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationStepTests.cs
@@ -0,0 +1,75 @@
+// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
+// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
+// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+using System.Collections.Generic;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.Bookmarks;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Bookmarks;
+
+// Next/Previous bookmark navigation must skip disabled bookmarks while still wrapping around.
+[TestFixture]
+public class BookmarkNavigationStepTests
+{
+ // Three bookmarks with the middle one disabled, so next/previous must step over it.
+ static readonly IReadOnlyList ThreeMiddleDisabled = new[] { true, false, true };
+
+ [Test]
+ public void Previous_from_the_third_skips_the_disabled_second()
+ {
+ // On index 2, "previous" must land on 0, not the disabled 1.
+ BookmarksPaneModel.NextEnabledIndex(ThreeMiddleDisabled, selectedIndex: 2, delta: -1).Should().Be(0);
+ }
+
+ [Test]
+ public void Next_from_the_first_skips_the_disabled_second()
+ {
+ BookmarksPaneModel.NextEnabledIndex(ThreeMiddleDisabled, selectedIndex: 0, delta: 1).Should().Be(2);
+ }
+
+ [Test]
+ public void Next_wraps_around_past_a_trailing_disabled()
+ {
+ // [enabled, enabled, disabled]; next from index 1 wraps to 0 (2 is disabled).
+ BookmarksPaneModel.NextEnabledIndex(new[] { true, true, false }, selectedIndex: 1, delta: 1).Should().Be(0);
+ }
+
+ [Test]
+ public void No_selection_picks_the_first_enabled_forward_and_last_enabled_backward()
+ {
+ var list = new[] { false, true, true, false };
+ BookmarksPaneModel.NextEnabledIndex(list, selectedIndex: -1, delta: 1).Should().Be(1);
+ BookmarksPaneModel.NextEnabledIndex(list, selectedIndex: -1, delta: -1).Should().Be(2);
+ }
+
+ [Test]
+ public void All_disabled_yields_nothing()
+ {
+ BookmarksPaneModel.NextEnabledIndex(new[] { false, false }, selectedIndex: 0, delta: 1).Should().BeNull();
+ }
+
+ [Test]
+ public void Empty_list_yields_nothing()
+ {
+ BookmarksPaneModel.NextEnabledIndex(new bool[0], selectedIndex: -1, delta: 1).Should().BeNull();
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs
new file mode 100644
index 0000000000..4685b2d012
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarkNavigationViewTests.cs
@@ -0,0 +1,243 @@
+// 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.Headless.NUnit;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+using AvaloniaEdit.Rendering;
+
+using AwesomeAssertions;
+
+using ICSharpCode.ILSpy.AppEnv;
+using ICSharpCode.ILSpy.Bookmarks;
+using ICSharpCode.ILSpy.Metadata;
+using ICSharpCode.ILSpy.Metadata.CorTables;
+using ICSharpCode.ILSpy.Properties;
+using ICSharpCode.ILSpy.TextView;
+using ICSharpCode.ILSpy.TreeNodes;
+
+using NUnit.Framework;
+
+namespace ICSharpCode.ILSpy.Tests.Bookmarks;
+
+[TestFixture]
+public class BookmarkNavigationViewTests
+{
+ // Regression for the bookmark hand-off when the active content is not a decompiler tab.
+ // Activating a bookmark from a metadata table (or Options / About) must still select the saved
+ // node, scroll its decompiled document to the bookmarked line, and play the one-shot highlight --
+ // the pending bookmark has to reach the decompiler model that ends up displaying the node, not
+ // the (absent) currently-active decompiler tab.
+ [AvaloniaTest]
+ public async Task Navigating_From_NonDecompiler_Content_Scrolls_To_And_Highlights_The_Bookmark()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+ var manager = AppComposition.Current.GetExport();
+ manager.Clear();
+
+ // Decompile System.Object and bookmark a line below the top so a scroll is observable.
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+ var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(objectNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var view = await window.WaitForComponent();
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(25);
+ }
+
+ int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount)
+ .Where(view.CanToggleBookmarkAtLine)
+ .Skip(3)
+ .First();
+ bookmarkLine.Should().BeGreaterThan(1, "the bookmark must sit below the top so the scroll is observable");
+ int offset = view.Editor.Document.GetLineByNumber(bookmarkLine).Offset;
+ // Put the caret on the bookmarked line before toggling, as Ctrl+B / a gutter click would, so
+ // the bookmark's captured view state agrees with its anchor.
+ view.Editor.TextArea.Caret.Offset = offset;
+ AppComposition.Current.GetExport()
+ .GetEntry(nameof(Resources.BookmarkToggle))
+ .Execute(new TextViewContext { TextView = view, TextLocation = offset });
+ Dispatcher.UIThread.RunJobs();
+ manager.Bookmarks.Should().ContainSingle();
+ var bookmark = manager.Bookmarks[0];
+
+ // Switch the active content to a metadata table: now there is no active decompiler tab.
+ var typeDefNode = vm.AssemblyTreeModel.FindCoreLib()
+ .GetChild()
+ .GetChild()
+ .GetChild();
+ vm.AssemblyTreeModel.SelectNode(typeDefNode);
+ await vm.DockWorkspace.WaitForMetadataTabAsync();
+ vm.DockWorkspace.ActiveDecompilerTab.Should().BeNull("the metadata table must be the active content");
+
+ // Activate the bookmark from there.
+ await AppComposition.Current.GetExport().NavigateToAsync(bookmark);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ view = await window.WaitForComponent();
+ // Wait until the one-shot highlight has registered, then assert without any further delay: the
+ // adorner self-dismisses after an ~800 ms lifetime, so a fixed-length pump on a loaded CI runner
+ // can outlast it and observe an empty collection. The same deferred apply also lands the caret.
+ await Waiters.WaitForAsync(() => view.Editor.TextArea.TextView.BackgroundRenderers
+ .OfType().Any());
+
+ int targetLine = view.GetLineForBookmark(bookmark) ?? -1;
+ targetLine.Should().BeGreaterThan(1, "the bookmark resolves to a line below the top in the freshly shown document");
+
+ // P1: the caret/scroll landed on the bookmark's line rather than the default top.
+ view.Editor.TextArea.Caret.Line.Should().Be(targetLine,
+ "bookmark navigation from non-decompiler content must scroll to the saved line");
+
+ // P2: the one-shot line highlight is playing on the freshly shown view.
+ view.Editor.TextArea.TextView.BackgroundRenderers.OfType()
+ .Should().ContainSingle("the destination line must be highlighted after the content switch");
+ }
+
+ // Regression: when the active tab is frozen, navigating to a bookmark in a different node must
+ // open a fresh preview tab, decompile the node, and still scroll to + highlight the bookmark.
+ // This exercises the fresh-decompile hand-off (PendingBookmark consumed in the document-apply
+ // step), distinct from re-showing an already-decompiled node.
+ [AvaloniaTest]
+ public async Task Navigating_To_Bookmark_With_A_Frozen_Active_Tab_Opens_And_Positions_A_Fresh_Preview()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+ var manager = AppComposition.Current.GetExport();
+ manager.Clear();
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+
+ // Bookmark a line in System.String.
+ var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String");
+ vm.AssemblyTreeModel.SelectNode(stringNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var view = await window.WaitForComponent();
+ await PumpLayoutAsync();
+
+ int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount)
+ .Where(view.CanToggleBookmarkAtLine)
+ .Skip(3)
+ .First();
+ bookmarkLine.Should().BeGreaterThan(1);
+ int offset = view.Editor.Document.GetLineByNumber(bookmarkLine).Offset;
+ view.Editor.TextArea.Caret.Offset = offset;
+ AppComposition.Current.GetExport()
+ .GetEntry(nameof(Resources.BookmarkToggle))
+ .Execute(new TextViewContext { TextView = view, TextLocation = offset });
+ Dispatcher.UIThread.RunJobs();
+ var bookmark = manager.Bookmarks.Should().ContainSingle().Subject;
+
+ // Show a different node, then freeze that tab so the next navigation must spawn a fresh preview.
+ var objectNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.Object");
+ vm.AssemblyTreeModel.SelectNode(objectNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ vm.DockWorkspace.FreezeCurrentTab();
+
+ // Activate the bookmark: a fresh preview tab must decompile System.String and land on the line.
+ await AppComposition.Current.GetExport().NavigateToAsync(bookmark);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+
+ var activeModel = vm.DockWorkspace.ActiveDecompilerTab;
+ activeModel.Should().NotBeNull("navigation must surface a decompiler tab");
+
+ // Wait until the fresh preview's view exists and its one-shot highlight has registered, then
+ // assert without any further delay: the adorner self-dismisses after an ~800 ms lifetime, so a
+ // fixed-length pump on a loaded CI runner can outlast it and observe an empty collection.
+ DecompilerTextView? ActiveView() => window.GetVisualDescendants().OfType()
+ .FirstOrDefault(v => ReferenceEquals(v.DataContext, activeModel));
+ await Waiters.WaitForAsync(() => ActiveView()?.Editor.TextArea.TextView.BackgroundRenderers
+ .OfType().Any() == true);
+ var activeView = ActiveView()!;
+
+ int targetLine = activeView.GetLineForBookmark(bookmark) ?? -1;
+ targetLine.Should().BeGreaterThan(1, "the fresh preview shows System.String with the bookmarked line below the top");
+ activeView.Editor.TextArea.Caret.Line.Should().Be(targetLine,
+ "opening a fresh preview for a frozen-tab navigation must still scroll to the bookmark");
+ activeView.Editor.TextArea.TextView.BackgroundRenderers.OfType()
+ .Should().ContainSingle("the destination line must be highlighted in the fresh preview");
+ }
+
+ // Regression: a bookmark re-anchors by token/IL offset, so a decompiler-setting change that
+ // reflows the text moves it to a different line than the one saved in its view state. Navigation
+ // must scroll to the re-resolved line; restoring the bookmark's stale saved caret/scroll instead
+ // would leave the bookmarked line off-screen with only the highlight playing where it can't be seen.
+ [AvaloniaTest]
+ public async Task Navigating_To_A_Bookmark_Scrolls_To_The_Resolved_Line_Not_The_Stale_Saved_Offset()
+ {
+ var (window, vm) = await TestHarness.BootAsync(3);
+ var manager = AppComposition.Current.GetExport();
+ manager.Clear();
+ var coreLibName = typeof(object).Assembly.GetName().Name!;
+
+ // A long type so the bookmark can sit well below the first screenful.
+ var stringNode = vm.AssemblyTreeModel.FindNode(coreLibName, "System", "System.String");
+ vm.AssemblyTreeModel.SelectNode(stringNode);
+ await vm.DockWorkspace.WaitForDecompiledTextAsync();
+ var view = await window.WaitForComponent();
+ await PumpLayoutAsync();
+
+ var textView = view.Editor.TextArea.TextView;
+ textView.EnsureVisualLines();
+
+ // Bookmark a line well below the initial viewport.
+ int bookmarkLine = Enumerable.Range(1, view.Editor.Document.LineCount)
+ .Where(view.CanToggleBookmarkAtLine)
+ .FirstOrDefault(l => textView.GetVisualTopByDocumentLine(l) > textView.Bounds.Height * 1.5);
+ bookmarkLine.Should().BeGreaterThan(0, "need a bookmarkable line well below the first screenful");
+ int offset = view.Editor.Document.GetLineByNumber(bookmarkLine).Offset;
+ view.Editor.TextArea.Caret.Offset = offset;
+ AppComposition.Current.GetExport()
+ .GetEntry(nameof(Resources.BookmarkToggle))
+ .Execute(new TextViewContext { TextView = view, TextLocation = offset });
+ Dispatcher.UIThread.RunJobs();
+ var bookmark = manager.Bookmarks.Should().ContainSingle().Subject;
+
+ // Simulate the post-reflow state: the bookmark still resolves (by token / IL offset) to its
+ // line, but the saved caret/scroll now point at the top of the document instead.
+ bookmark.ViewState.Should().NotBeNull();
+ bookmark.ViewState = bookmark.ViewState! with { CaretOffset = 0, VerticalOffset = 0, HorizontalOffset = 0 };
+
+ // Navigate to the bookmark on the already-shown node.
+ vm.DockWorkspace.ActiveDecompilerTab!.ScrollToBookmark!.Invoke(bookmark);
+ await PumpLayoutAsync();
+
+ int targetLine = view.GetLineForBookmark(bookmark) ?? -1;
+ targetLine.Should().Be(bookmarkLine, "no real reflow happened, so the bookmark still resolves to its line");
+
+ view.Editor.TextArea.Caret.Line.Should().Be(targetLine,
+ "navigation must position by the re-resolved line, not the bookmark's stale saved caret");
+
+ double visualTop = textView.GetVisualTopByDocumentLine(targetLine);
+ visualTop.Should().BeInRange(textView.VerticalOffset, textView.VerticalOffset + textView.Bounds.Height,
+ "the bookmarked line must be on-screen after navigation, not scrolled away by the stale saved offset");
+ }
+
+ static async Task PumpLayoutAsync()
+ {
+ for (int i = 0; i < 8; i++)
+ {
+ Dispatcher.UIThread.RunJobs();
+ await Task.Delay(25);
+ }
+ }
+}
diff --git a/ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs b/ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs
new file mode 100644
index 0000000000..39dae06f5b
--- /dev/null
+++ b/ILSpy.Tests/Bookmarks/BookmarksPaneStructureTests.cs
@@ -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("ToolbarBorder")!;
+ var toolbarRoot = pane.FindControl("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().Should().HaveCount(2);
+ toolbarRoot.Children.OfType