diff --git a/ILSpy.Tests/Search/ScopeSearchToTests.cs b/ILSpy.Tests/Search/ScopeSearchToTests.cs new file mode 100644 index 000000000..8a9ba5d9c --- /dev/null +++ b/ILSpy.Tests/Search/ScopeSearchToTests.cs @@ -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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.ScopeSearchToThisAssembly)) + .Value; + + var asm = vm.AssemblyTreeModel.FindNode("System.Linq"); + var search = AppComposition.Current.GetExport(); + 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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var registry = AppComposition.Current.GetExport(); + var entry = registry.Entries + .Single(e => e.Metadata.Header == nameof(Resources.ScopeSearchToThisNamespace)) + .Value; + + var ns = vm.AssemblyTreeModel.FindNode("System.Linq", "System.Linq"); + var search = AppComposition.Current.GetExport(); + 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; + } +} diff --git a/ILSpy.Tests/Search/SearchResultNavigationTests.cs b/ILSpy.Tests/Search/SearchResultNavigationTests.cs new file mode 100644 index 000000000..a2aa38914 --- /dev/null +++ b/ILSpy.Tests/Search/SearchResultNavigationTests.cs @@ -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(); + window.Show(); + var vm = (MainWindowViewModel)window.DataContext!; + await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); + + var search = AppComposition.Current.GetExport(); + 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; + } +} diff --git a/ILSpy/Search/RunningSearch.cs b/ILSpy/Search/RunningSearch.cs index c2a1a7bd0..3ae627550 100644 --- a/ILSpy/Search/RunningSearch.cs +++ b/ILSpy/Search/RunningSearch.cs @@ -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(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!, }; } diff --git a/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs b/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs new file mode 100644 index 000000000..d56557261 --- /dev/null +++ b/ILSpy/Search/ScopeSearchToAssemblyContextMenuEntry.cs @@ -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 +{ + /// + /// Right-click an → "Search within this assembly". + /// Prepends inassembly:<short-name> to the live search term so subsequent + /// matches are restricted to that assembly. The minimal parser in + /// recognises the prefix and feeds it into the + /// SearchRequest.InAssembly filter the ILSpyX strategies consult. + /// + [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().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); + } + } +} diff --git a/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs b/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs new file mode 100644 index 000000000..2adf8561a --- /dev/null +++ b/ILSpy/Search/ScopeSearchToNamespaceContextMenuEntry.cs @@ -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 +{ + /// + /// Right-click a → "Search within this namespace". + /// Prepends innamespace:<namespace> to the live search term so subsequent + /// matches are restricted to that namespace. Same parser/filter path as + /// . + /// + [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().FirstOrDefault(); + if (ns == null || string.IsNullOrEmpty(ns.Name)) + return; + searchPane.SearchTerm = ScopeSearchToAssemblyContextMenuEntry.MergeScopePrefix( + searchPane.SearchTerm, "innamespace", ns.Name); + } + } +} diff --git a/ILSpy/Search/SearchPane.axaml.cs b/ILSpy/Search/SearchPane.axaml.cs index 18d818f9a..292f535b3 100644 --- a/ILSpy/Search/SearchPane.axaml.cs +++ b/ILSpy/Search/SearchPane.axaml.cs @@ -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 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; + } } } } diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index 9ad312198..a0067e62d 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -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 /// public ObservableCollection Results { get; } = new(); + /// + /// User clicked (or double-tapped) a result row. Walks the result's Reference + /// to the matching assembly-tree node via + /// 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). + /// + 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()