Browse Source

Result navigation + scope-to context-menu entries

SearchPaneModel gains an Activate(SearchResult) method that resolves
result.Reference through AssemblyTreeModel.FindTreeNode and moves the
assembly-tree selection there — exactly the WPF NavigateToReferenceEventArgs
flow, minus the message-bus indirection. SearchPane.axaml.cs wires
DoubleTapped + Enter on the results ListBox to call Activate.

Assisted-by: Claude:claude-opus-4-7:Claude Code
pull/3755/head
Siegfried Pammer 2 months ago
parent
commit
44383d87cc
  1. 97
      ILSpy.Tests/Search/ScopeSearchToTests.cs
  2. 73
      ILSpy.Tests/Search/SearchResultNavigationTests.cs
  3. 38
      ILSpy/Search/RunningSearch.cs
  4. 81
      ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs
  5. 66
      ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs
  6. 27
      ILSpy/Search/SearchPane.axaml.cs
  7. 21
      ILSpy/Search/SearchPaneModel.cs

97
ILSpy.Tests/Search/ScopeSearchToTests.cs

@ -0,0 +1,97 @@ @@ -0,0 +1,97 @@
// 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 AwesomeAssertions;
using ICSharpCode.ILSpy.Properties;
using ICSharpCode.ILSpyX.TreeView;
using ILSpy;
using ILSpy.AppEnv;
using ILSpy.Search;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Search;
[TestFixture]
public class ScopeSearchToTests
{
[AvaloniaTest]
public async Task Scope_Search_To_Assembly_Prepends_InAssembly_To_The_Search_Term()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources.ScopeSearchToThisAssembly))
.Value;
var asm = vm.AssemblyTreeModel.FindNode<AssemblyTreeNode>("System.Linq");
var search = AppComposition.Current.GetExport<SearchPaneModel>();
search.SearchTerm = string.Empty;
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } })
.Should().BeTrue("the entry must surface on AssemblyTreeNode selections");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { asm } });
search.SearchTerm.Should().Contain("inassembly:",
"Execute must inject the inassembly: prefix into the live search term");
search.SearchTerm.Should().Contain("System.Linq");
search.SearchTerm = string.Empty;
}
[AvaloniaTest]
public async Task Scope_Search_To_Namespace_Prepends_InNamespace_To_The_Search_Term()
{
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var registry = AppComposition.Current.GetExport<ContextMenuEntryRegistry>();
var entry = registry.Entries
.Single(e => e.Metadata.Header == nameof(Resources.ScopeSearchToThisNamespace))
.Value;
var ns = vm.AssemblyTreeModel.FindNode<NamespaceTreeNode>("System.Linq", "System.Linq");
var search = AppComposition.Current.GetExport<SearchPaneModel>();
search.SearchTerm = string.Empty;
entry.IsVisible(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } })
.Should().BeTrue("the entry must surface on NamespaceTreeNode selections");
entry.Execute(new TextViewContext { SelectedTreeNodes = new SharpTreeNode[] { ns } });
search.SearchTerm.Should().Contain("innamespace:");
search.SearchTerm.Should().Contain("System.Linq");
search.SearchTerm = string.Empty;
}
}

73
ILSpy.Tests/Search/SearchResultNavigationTests.cs

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
// 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.Linq;
using System.Threading.Tasks;
using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpyX.Search;
using ILSpy.AppEnv;
using ILSpy.Search;
using ILSpy.TreeNodes;
using ILSpy.ViewModels;
using ILSpy.Views;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests.Search;
[TestFixture]
public class SearchResultNavigationTests
{
[AvaloniaTest]
public async Task Activating_A_Member_Search_Result_Selects_That_Member_In_The_Assembly_Tree()
{
// End-to-end: run a search, take the first MemberSearchResult, ask the search pane
// to activate it, and confirm the assembly tree's SelectedItem becomes the matching
// tree node.
var window = AppComposition.Current.GetExport<MainWindow>();
window.Show();
var vm = (MainWindowViewModel)window.DataContext!;
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1);
var search = AppComposition.Current.GetExport<SearchPaneModel>();
search.Results.Clear();
search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type);
search.SearchTerm = "Enumerable";
await Waiters.WaitForAsync(() => search.Results.Any(r => r is MemberSearchResult),
timeout: TimeSpan.FromSeconds(30));
var hit = (MemberSearchResult)search.Results.First(r => r is MemberSearchResult);
search.Activate(hit);
await Waiters.WaitForAsync(() => vm.AssemblyTreeModel.SelectedItem is TypeTreeNode);
((object?)vm.AssemblyTreeModel.SelectedItem).Should().NotBeNull(
"Activate must move the assembly-tree selection to the result's underlying entity");
(vm.AssemblyTreeModel.SelectedItem is TypeTreeNode).Should().BeTrue(
"the type-mode search returned a TypeDefinition; the assembly tree must land on the matching TypeTreeNode");
// Tidy up so subsequent tests don't see leftover term.
search.SearchTerm = string.Empty;
}
}

38
ILSpy/Search/RunningSearch.cs

@ -141,29 +141,41 @@ namespace ILSpy.Search @@ -141,29 +141,41 @@ namespace ILSpy.Search
SearchRequest BuildRequest()
{
// Minimal keyword parser: split on whitespace. The WPF pane supports a richer
// prefix DSL (inassembly:, t:, /regex/, =exact, ~fuzzy, …) — that lands as a
// follow-up. Plain-keyword search covers the common case end-to-end.
var keywords = (searchTerm ?? string.Empty)
// Lightweight parser: split on whitespace, pull off the two scope prefixes
// (inassembly: / innamespace:) when they appear. The full WPF prefix DSL
// (/regex/, =exact, ~fuzzy, t: / m:, @token, …) is out of scope; plain
// keyword + scope-to context-menu integration covers the common cases.
var tokens = (searchTerm ?? string.Empty)
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var keywords = new List<string>(tokens.Length);
string? inAssembly = null;
string? inNamespace = null;
foreach (var token in tokens)
{
if (token.StartsWith("inassembly:", StringComparison.OrdinalIgnoreCase))
inAssembly = token.Substring("inassembly:".Length).Trim('"');
else if (token.StartsWith("innamespace:", StringComparison.OrdinalIgnoreCase))
inNamespace = token.Substring("innamespace:".Length).Trim('"');
else
keywords.Add(token);
}
return new SearchRequest {
Mode = mode,
Keywords = keywords,
Keywords = keywords.ToArray(),
SearchResultFactory = resultFactory,
// MemberSearchStrategy.Search resolves a type system via
// module.GetTypeSystemWithDecompilerSettingsOrNull(request.DecompilerSettings);
// passing null short-circuits to zero results. Default settings are fine for
// search — we only need the type system to materialise, not specific
// decompiler behaviour.
// search — we only need the type system to materialise.
DecompilerSettings = new DecompilerSettings(),
FullNameSearch = false,
OmitGenerics = false,
// IsInNamespaceOrAssembly treats null as "no filter, accept everything";
// the EMPTY STRING would restrict to the global namespace (Namespace.Length
// == 0), which matches almost nothing. The DSL prefixes that flip these on
// (innamespace:, inassembly:) are out of scope for the minimal parser.
InNamespace = null!,
InAssembly = null!,
// IsInNamespaceOrAssembly: null means "no filter, accept everything";
// the EMPTY STRING would restrict to the global namespace, matching almost
// nothing. The scope-to context-menu entries set these via the DSL prefixes
// parsed above.
InNamespace = inNamespace!,
InAssembly = inAssembly!,
};
}

81
ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Composition;
using System.Linq;
using ICSharpCode.ILSpy.Properties;
using ILSpy.TreeNodes;
namespace ILSpy.Search
{
/// <summary>
/// Right-click an <see cref="AssemblyTreeNode"/> → "Search within this assembly".
/// Prepends <c>inassembly:&lt;short-name&gt;</c> to the live search term so subsequent
/// matches are restricted to that assembly. The minimal parser in
/// <see cref="RunningSearch"/> recognises the prefix and feeds it into the
/// <c>SearchRequest.InAssembly</c> filter the ILSpyX strategies consult.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.ScopeSearchToThisAssembly), Order = 1100)]
[Shared]
public sealed class ScopeSearchToAssemblyContextMenuEntry : IContextMenuEntry
{
readonly SearchPaneModel searchPane;
[ImportingConstructor]
public ScopeSearchToAssemblyContextMenuEntry(SearchPaneModel searchPane)
{
this.searchPane = searchPane;
}
public bool IsVisible(TextViewContext context)
{
if (context.SelectedTreeNodes is not { Length: > 0 } nodes)
return false;
return nodes.All(n => n is AssemblyTreeNode);
}
public bool IsEnabled(TextViewContext context) => IsVisible(context);
public void Execute(TextViewContext context)
{
if (context.SelectedTreeNodes is not { Length: > 0 } nodes)
return;
var asm = nodes.OfType<AssemblyTreeNode>().FirstOrDefault();
if (asm == null)
return;
var name = asm.LoadedAssembly.ShortName;
searchPane.SearchTerm = MergeScopePrefix(searchPane.SearchTerm, "inassembly", name);
}
internal static string MergeScopePrefix(string current, string prefix, string value)
{
// Strip any existing same-prefix token so consecutive scope clicks REPLACE rather
// than stack. The value may need quoting if it contains spaces — assembly short
// names typically don't, but quote anyway for safety.
var tokens = (current ?? string.Empty)
.Split(' ', System.StringSplitOptions.RemoveEmptyEntries | System.StringSplitOptions.TrimEntries)
.Where(t => !t.StartsWith(prefix + ":", System.StringComparison.OrdinalIgnoreCase))
.ToList();
var quoted = value.Contains(' ') ? "\"" + value + "\"" : value;
tokens.Insert(0, prefix + ":" + quoted);
return string.Join(' ', tokens);
}
}
}

66
ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System.Composition;
using System.Linq;
using ICSharpCode.ILSpy.Properties;
using ILSpy.TreeNodes;
namespace ILSpy.Search
{
/// <summary>
/// Right-click a <see cref="NamespaceTreeNode"/> → "Search within this namespace".
/// Prepends <c>innamespace:&lt;namespace&gt;</c> to the live search term so subsequent
/// matches are restricted to that namespace. Same parser/filter path as
/// <see cref="ScopeSearchToAssemblyContextMenuEntry"/>.
/// </summary>
[ExportContextMenuEntry(Header = nameof(Resources.ScopeSearchToThisNamespace), Order = 1101)]
[Shared]
public sealed class ScopeSearchToNamespaceContextMenuEntry : IContextMenuEntry
{
readonly SearchPaneModel searchPane;
[ImportingConstructor]
public ScopeSearchToNamespaceContextMenuEntry(SearchPaneModel searchPane)
{
this.searchPane = searchPane;
}
public bool IsVisible(TextViewContext context)
{
if (context.SelectedTreeNodes is not { Length: > 0 } nodes)
return false;
return nodes.All(n => n is NamespaceTreeNode);
}
public bool IsEnabled(TextViewContext context) => IsVisible(context);
public void Execute(TextViewContext context)
{
if (context.SelectedTreeNodes is not { Length: > 0 } nodes)
return;
var ns = nodes.OfType<NamespaceTreeNode>().FirstOrDefault();
if (ns == null || string.IsNullOrEmpty(ns.Name))
return;
searchPane.SearchTerm = ScopeSearchToAssemblyContextMenuEntry.MergeScopePrefix(
searchPane.SearchTerm, "innamespace", ns.Name);
}
}
}

27
ILSpy/Search/SearchPane.axaml.cs

@ -17,6 +17,9 @@ @@ -17,6 +17,9 @@
// DEALINGS IN THE SOFTWARE.
using Avalonia.Controls;
using Avalonia.Input;
using ICSharpCode.ILSpyX.Search;
namespace ILSpy.Search
{
@ -25,6 +28,30 @@ namespace ILSpy.Search @@ -25,6 +28,30 @@ namespace ILSpy.Search
public SearchPane()
{
InitializeComponent();
SearchResults.DoubleTapped += OnResultDoubleTapped;
SearchResults.KeyDown += OnResultKeyDown;
}
void OnResultDoubleTapped(object? sender, TappedEventArgs e)
{
if (SearchResults.SelectedItem is SearchResult result && DataContext is SearchPaneModel vm)
{
vm.Activate(result);
e.Handled = true;
}
}
void OnResultKeyDown(object? sender, KeyEventArgs e)
{
// Enter as the keyboard equivalent of double-tap so users navigating the list
// with arrow keys can activate without reaching for the mouse.
if (e.Key != Key.Enter)
return;
if (SearchResults.SelectedItem is SearchResult result && DataContext is SearchPaneModel vm)
{
vm.Activate(result);
e.Handled = true;
}
}
}
}

21
ILSpy/Search/SearchPaneModel.cs

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Collections.ObjectModel;
using System.Composition;
@ -106,6 +107,26 @@ namespace ILSpy.Search @@ -106,6 +107,26 @@ namespace ILSpy.Search
/// </summary>
public ObservableCollection<SearchResult> Results { get; } = new();
/// <summary>
/// User clicked (or double-tapped) a result row. Walks the result's <c>Reference</c>
/// to the matching assembly-tree node via <see cref="AssemblyTreeModel.FindTreeNode"/>
/// and moves selection there. Silently no-ops when the reference can't be resolved
/// (resource and assembly results have non-entity references that the assembly tree
/// doesn't index — a follow-up commit can extend FindTreeNode to cover them).
/// </summary>
public void Activate(SearchResult result)
{
ArgumentNullException.ThrowIfNull(result);
if (result.Reference is null)
return;
var atm = TryGetAssemblyTreeModel();
if (atm == null)
return;
var node = atm.FindTreeNode(result.Reference);
if (node != null)
atm.SelectedItem = node;
}
RunningSearch? currentSearch;
void RestartSearch()

Loading…
Cancel
Save