diff --git a/ILSpy.Tests/Controls/OmnibarBreadcrumbTests.cs b/ILSpy.Tests/Controls/OmnibarBreadcrumbTests.cs new file mode 100644 index 000000000..d6351fc58 --- /dev/null +++ b/ILSpy.Tests/Controls/OmnibarBreadcrumbTests.cs @@ -0,0 +1,120 @@ +// 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 ICSharpCode.ILSpy.Controls.Omnibar; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Controls; + +/// +/// The omnibar breadcrumb is the decompiler's "you are here" trail: it must mirror the +/// assembly-tree path of the decompiled node (Assembly > Namespace > Type > Member), excluding +/// the invisible tree root, and clicking a segment must move the tree selection there. +/// +[TestFixture] +public class OmnibarBreadcrumbTests +{ + [AvaloniaTest] + public async Task BuildSegments_Yields_Assembly_Namespace_Type_Member_In_Order() + { + var (_, vm) = await TestHarness.BootAsync(3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.EnsureLazyChildren(); + var methodNode = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + var segments = OmnibarViewModel.BuildSegments(methodNode); + + Assert.That(segments, Has.Count.EqualTo(4), + "a member node breadcrumb is Assembly > Namespace > Type > Member (root excluded)"); + Assert.That(segments[0].Node, Is.InstanceOf(), + "the first crumb is the assembly, not the invisible AssemblyListTreeNode root"); + Assert.That(segments[1].Node, Is.InstanceOf()); + Assert.That(segments[1].Text, Is.EqualTo("System.Linq")); + Assert.That(segments[2].Node, Is.SameAs(typeNode)); + Assert.That(segments[3].Node, Is.SameAs(methodNode)); + Assert.That(segments[3].Text, Does.Contain("Empty")); + } + + [AvaloniaTest] + public async Task Leaf_Crumb_Has_No_Children_While_Container_Crumbs_Do() + { + var (_, vm) = await TestHarness.BootAsync(3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.EnsureLazyChildren(); + var methodNode = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + var segments = OmnibarViewModel.BuildSegments(methodNode); + + // Container crumbs (assembly / namespace / type) host children, so they keep the chevron. + Assert.That(segments[0].HasChildren, Is.True, "the assembly crumb has children"); + Assert.That(segments[2].HasChildren, Is.True, "the type crumb has members"); + // The method is a leaf: no chevron, so it can't open an empty dropdown. + Assert.That(segments[3].HasChildren, Is.False, "a method crumb is a leaf and drops the chevron"); + } + + [AvaloniaTest] + public async Task NavigateSegment_Moves_The_Tree_Selection() + { + var (_, vm) = await TestHarness.BootAsync(3); + + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + typeNode.EnsureLazyChildren(); + var methodNode = typeNode.Children.OfType() + .First(m => m.MethodDefinition.Name == "Empty"); + + var omnibar = new OmnibarViewModel(); + omnibar.SetNode(methodNode); + + // Click the "Type" crumb (index 2): selection jumps from the method to its type. + omnibar.NavigateSegment(omnibar.Segments[2]); + + Assert.That(vm.AssemblyTreeModel.SelectedItem, Is.SameAs(typeNode)); + } + + [AvaloniaTest] + public async Task EnterSearch_And_ExitSearch_Toggle_Mode_And_Clear_Text() + { + await TestHarness.BootAsync(1); + + var omnibar = new OmnibarViewModel(); + Assert.That(omnibar.Mode, Is.EqualTo(OmnibarMode.Breadcrumb)); + + omnibar.EnterSearch(); + Assert.That(omnibar.Mode, Is.EqualTo(OmnibarMode.Search)); + + omnibar.SearchText = "Enumerable"; + omnibar.ExitSearch(); + + Assert.That(omnibar.Mode, Is.EqualTo(OmnibarMode.Breadcrumb)); + Assert.That(omnibar.SearchText, Is.Empty); + } +} diff --git a/ILSpy.Tests/Controls/OmnibarSettingTests.cs b/ILSpy.Tests/Controls/OmnibarSettingTests.cs new file mode 100644 index 000000000..61c8d9d67 --- /dev/null +++ b/ILSpy.Tests/Controls/OmnibarSettingTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Linq; +using System.Threading.Tasks; + +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.Controls.Omnibar; +using ICSharpCode.ILSpy.TextView; +using ICSharpCode.ILSpy.TreeNodes; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests.Controls; + +/// +/// The omnibar ships off by default behind the Options / Display "Tab options" +/// EnableOmnibar toggle, and the toggle takes effect live (no re-decompile). +/// +[TestFixture] +public class OmnibarSettingTests +{ + [AvaloniaTest] + public async Task Omnibar_Is_Off_By_Default_And_Revealed_Live_When_Enabled() + { + var (window, vm) = await TestHarness.BootAsync(3); + var display = AppComposition.Current.GetExport().DisplaySettings; + Assert.That(display.EnableOmnibar, Is.False, "the omnibar is off by default"); + + // Realize a decompiler document by selecting a node. + var typeNode = vm.AssemblyTreeModel.FindNode( + "System.Linq", "System.Linq", "System.Linq.Enumerable"); + vm.AssemblyTreeModel.SelectedItem = typeNode; + Omnibar? omnibar = null; + for (int i = 0; i < 200; i++) + { + Dispatcher.UIThread.RunJobs(); + omnibar = window.GetVisualDescendants().OfType() + .Where(v => v.IsEffectivelyVisible) + .SelectMany(v => v.GetVisualDescendants().OfType()) + .FirstOrDefault(); + if (omnibar != null) + break; + await Task.Delay(20); + } + Assert.That(omnibar, Is.Not.Null, "selecting a node realizes a decompiler text view hosting the omnibar"); + + Assert.That(omnibar!.IsVisible, Is.False, + "the omnibar is hidden while the EnableOmnibar setting is off"); + + display.EnableOmnibar = true; + Dispatcher.UIThread.RunJobs(); + + Assert.That(omnibar.IsVisible, Is.True, + "enabling the setting reveals the omnibar without a re-decompile"); + } +} diff --git a/ILSpy/App.axaml b/ILSpy/App.axaml index b89594b1f..23c7988d0 100644 --- a/ILSpy/App.axaml +++ b/ILSpy/App.axaml @@ -90,6 +90,11 @@ + + + + @@ -142,6 +147,11 @@ + + + + diff --git a/ILSpy/Controls/Omnibar/BreadcrumbSegment.cs b/ILSpy/Controls/Omnibar/BreadcrumbSegment.cs new file mode 100644 index 000000000..2970e84cd --- /dev/null +++ b/ILSpy/Controls/Omnibar/BreadcrumbSegment.cs @@ -0,0 +1,56 @@ +// 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 ICSharpCode.ILSpyX.TreeView; + +namespace ICSharpCode.ILSpy.Controls.Omnibar +{ + /// + /// One crumb in the omnibar breadcrumb: a tree node rendered as icon + label. The + /// is the live the crumb stands for, so clicking + /// the crumb (or one of its from the chevron dropdown) navigates the + /// assembly tree straight there. Used both for the trail itself and for the sibling entries the + /// chevron lists. + /// + public sealed class BreadcrumbSegment + { + public BreadcrumbSegment(SharpTreeNode node) + { + Node = node; + Text = node.Text?.ToString() ?? string.Empty; + Icon = node.Icon; + } + + /// The tree node this crumb represents; navigating selects it. + public SharpTreeNode Node { get; } + + /// Display label, taken from . + public string Text { get; } + + /// Display icon (an IImage), taken from . + public object? Icon { get; } + + /// + /// Whether the chevron dropdown has anything to show. Reuses the same predicate the tree uses + /// for its expander (: lazy-loadable, or has visible + /// children), so leaf crumbs such as methods and fields drop the chevron instead of opening an + /// empty list. + /// + public bool HasChildren => Node.ShowExpander; + } +} diff --git a/ILSpy/Controls/Omnibar/Omnibar.axaml b/ILSpy/Controls/Omnibar/Omnibar.axaml new file mode 100644 index 000000000..221e38527 --- /dev/null +++ b/ILSpy/Controls/Omnibar/Omnibar.axaml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ILSpy/Controls/Omnibar/Omnibar.axaml.cs b/ILSpy/Controls/Omnibar/Omnibar.axaml.cs new file mode 100644 index 000000000..208d4c131 --- /dev/null +++ b/ILSpy/Controls/Omnibar/Omnibar.axaml.cs @@ -0,0 +1,214 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Threading; + +using ICSharpCode.ILSpyX.Search; +using ICSharpCode.ILSpyX.TreeView; + +namespace ICSharpCode.ILSpy.Controls.Omnibar +{ + /// + /// The decompiler's address-bar: a breadcrumb of the current node that turns into a search box + /// when the user types. Hosted at the top of the decompiled-content view; one instance per + /// document tab, each owning its own . + /// + public partial class Omnibar : UserControl + { + readonly OmnibarViewModel viewModel = new(); + + public Omnibar() + { + // Per-instance VM (not inherited from the hosting document's DataContext) so each tab's + // bar tracks its own node and search. + DataContext = viewModel; + InitializeComponent(); + + viewModel.PropertyChanged += OnViewModelPropertyChanged; + viewModel.Suggestions.CollectionChanged += OnSuggestionsChanged; + + SearchInput.KeyDown += OnSearchInputKeyDown; + SuggestionsList.KeyDown += OnSuggestionsKeyDown; + SuggestionsList.Tapped += OnSuggestionTapped; + ChevronList.Tapped += OnChevronItemTapped; + ChevronList.KeyDown += OnChevronKeyDown; + } + + /// Points the breadcrumb at the document's current node. + public void SetNode(SharpTreeNode? node) => viewModel.SetNode(node); + + /// Drops the bar into search mode and focuses the query box (Ctrl+L from the host). + public void FocusSearch() + { + viewModel.EnterSearch(); + Dispatcher.UIThread.Post(() => { + SearchInput.Focus(); + SearchInput.SelectAll(); + }); + } + + void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(OmnibarViewModel.Mode)) + { + if (viewModel.Mode == OmnibarMode.Search) + { + Dispatcher.UIThread.Post(() => SearchInput.Focus()); + } + else + { + SuggestionsPopup.IsOpen = false; + } + } + } + + void OnSuggestionsChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + SuggestionsPopup.IsOpen = viewModel.Mode == OmnibarMode.Search && viewModel.Suggestions.Count > 0; + } + + void OnSegmentClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (sender is Control { Tag: BreadcrumbSegment segment }) + viewModel.NavigateSegment(segment); + } + + void OnEnterSearchClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + => FocusSearch(); + + void OnClearSearchClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + viewModel.SearchText = string.Empty; + SearchInput.Focus(); + } + + void OnSearchInputKeyDown(object? sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Escape: + viewModel.ExitSearch(); + e.Handled = true; + break; + case Key.Down when viewModel.Suggestions.Count > 0: + SuggestionsPopup.IsOpen = true; + SuggestionsList.SelectedIndex = 0; + SuggestionsList.Focus(); + e.Handled = true; + break; + case Key.Enter: + ActivateSuggestion( + SuggestionsList.SelectedItem as SearchResult ?? viewModel.Suggestions.FirstOrDefault(), + inNewTabPage: e.KeyModifiers.HasFlag(KeyModifiers.Control)); + e.Handled = true; + break; + } + } + + void OnSuggestionsKeyDown(object? sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + ActivateSuggestion(SuggestionsList.SelectedItem as SearchResult, + inNewTabPage: e.KeyModifiers.HasFlag(KeyModifiers.Control)); + e.Handled = true; + break; + case Key.Escape: + viewModel.ExitSearch(); + e.Handled = true; + break; + case Key.Up when SuggestionsList.SelectedIndex <= 0: + SearchInput.Focus(); + e.Handled = true; + break; + } + } + + void OnSuggestionTapped(object? sender, TappedEventArgs e) + => ActivateSuggestion(SuggestionsList.SelectedItem as SearchResult, inNewTabPage: false); + + void ActivateSuggestion(SearchResult? result, bool inNewTabPage) + { + if (result == null) + return; + SuggestionsPopup.IsOpen = false; + viewModel.Activate(result, inNewTabPage); + } + + // The crumb whose children the chevron popup is currently showing; used to refilter as the + // user types in the popup's filter box. + System.Collections.Generic.IReadOnlyList chevronChildren + = Array.Empty(); + + void OnChevronClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (sender is not Control { Tag: BreadcrumbSegment segment } button) + return; + chevronChildren = viewModel.GetChildSegments(segment); + ChevronFilter.Text = string.Empty; + ChevronList.ItemsSource = chevronChildren; + ChevronPopup.PlacementTarget = button; + ChevronPopup.IsOpen = chevronChildren.Count > 0; + if (ChevronPopup.IsOpen) + Dispatcher.UIThread.Post(() => ChevronFilter.Focus()); + } + + void OnChevronFilterChanged(object? sender, TextChangedEventArgs e) + { + var filter = ChevronFilter.Text ?? string.Empty; + ChevronList.ItemsSource = filter.Length == 0 + ? chevronChildren + : chevronChildren + .Where(c => c.Text.Contains(filter, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + } + + void OnChevronItemTapped(object? sender, TappedEventArgs e) + => NavigateToChevronSelection(); + + void OnChevronKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + NavigateToChevronSelection(); + e.Handled = true; + } + else if (e.Key == Key.Escape) + { + ChevronPopup.IsOpen = false; + e.Handled = true; + } + } + + void NavigateToChevronSelection() + { + if (ChevronList.SelectedItem is not BreadcrumbSegment segment) + return; + ChevronPopup.IsOpen = false; + viewModel.NavigateSegment(segment); + } + } +} diff --git a/ILSpy/Controls/Omnibar/OmnibarMode.cs b/ILSpy/Controls/Omnibar/OmnibarMode.cs new file mode 100644 index 000000000..95392f86c --- /dev/null +++ b/ILSpy/Controls/Omnibar/OmnibarMode.cs @@ -0,0 +1,32 @@ +// 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. + +namespace ICSharpCode.ILSpy.Controls.Omnibar +{ + /// + /// The two faces of the omnibar. shows the path of the decompiled + /// node and is the resting state; turns the bar into a query input with + /// an inline suggestions dropdown. Typing switches into ; Escape (or + /// navigating to a result) returns to . + /// + public enum OmnibarMode + { + Breadcrumb, + Search, + } +} diff --git a/ILSpy/Controls/Omnibar/OmnibarViewModel.cs b/ILSpy/Controls/Omnibar/OmnibarViewModel.cs new file mode 100644 index 000000000..1f9099978 --- /dev/null +++ b/ILSpy/Controls/Omnibar/OmnibarViewModel.cs @@ -0,0 +1,255 @@ +// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; + +using ICSharpCode.ILSpyX; +using ICSharpCode.ILSpyX.Search; +using ICSharpCode.ILSpyX.TreeView; + +using ICSharpCode.ILSpy.AppEnv; +using ICSharpCode.ILSpy.AssemblyTree; +using ICSharpCode.ILSpy.Languages; +using ICSharpCode.ILSpy.Options; +using ICSharpCode.ILSpy.Search; +using ICSharpCode.ILSpy.ViewModels; + +namespace ICSharpCode.ILSpy.Controls.Omnibar +{ + /// + /// Drives one omnibar instance: the breadcrumb trail for the hosting document's current node + /// and the inline search that the bar turns into when the user types. Created per-control (not + /// MEF-shared) so each document tab carries its own trail; shared services (assembly tree, + /// language, settings, search engine) are resolved lazily from the composition host the same + /// way does. + /// + public sealed partial class OmnibarViewModel : ViewModelBase + { + // The inline dropdown shows the best few hits; the docked Search pane (Ctrl+Shift+F) stays + // the place for the full, sortable result list. + const int MaxSuggestions = 20; + + /// The breadcrumb trail, root-first (Assembly > Namespace > Type > Member). + public ObservableCollection Segments { get; } = new(); + + /// The best hits of the live search, fitness-sorted. + public ObservableCollection Suggestions { get; } = new(); + + // Full streaming sink for the current RunningSearch (kept fitness-sorted by the engine). + // Suggestions mirrors only its head; this stays private so the bound collection never shows + // the engine's 1000-result cap row. + readonly ObservableCollection searchSink = new(); + + [ObservableProperty] + public partial OmnibarMode Mode { get; set; } = OmnibarMode.Breadcrumb; + + [ObservableProperty] + public partial string SearchText { get; set; } = string.Empty; + + [ObservableProperty] + public partial bool IsSearching { get; set; } + + /// True while the bar shows the breadcrumb trail. Drives view visibility. + public bool IsBreadcrumbMode => Mode == OmnibarMode.Breadcrumb; + + /// True while the bar shows the search input. Drives view visibility. + public bool IsSearchMode => Mode == OmnibarMode.Search; + + public OmnibarViewModel() + { + PropertyChanged += OnPropertyChangedDispatch; + searchSink.CollectionChanged += OnSearchSinkChanged; + } + + partial void OnModeChanged(OmnibarMode value) + { + OnPropertyChanged(nameof(IsBreadcrumbMode)); + OnPropertyChanged(nameof(IsSearchMode)); + } + + void OnPropertyChangedDispatch(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName != nameof(SearchText)) + return; + // Typing anything turns the bar into a search box; clearing it (or Escape) is what + // returns to the breadcrumb, handled by ExitSearch. + if (!string.IsNullOrEmpty(SearchText)) + Mode = OmnibarMode.Search; + RestartSearch(); + } + + // The node currently shown in the host document, tracked so a repeated SetNode (e.g. an + // intermediate decompile property change) doesn't rebuild the trail or kick the user out + // of a search they're in the middle of typing. + SharpTreeNode? currentNode; + + /// + /// Points the breadcrumb at (the document's current node). A real + /// change rebuilds the trail and leaves search mode, since the displayed location moved. + /// + public void SetNode(SharpTreeNode? node) + { + if (ReferenceEquals(currentNode, node)) + return; + currentNode = node; + Segments.Clear(); + foreach (var segment in BuildSegments(node)) + Segments.Add(segment); + if (Mode == OmnibarMode.Search) + ExitSearch(); + } + + /// + /// The breadcrumb for : its ancestor chain root-first, excluding the + /// invisible AssemblyListTreeNode root (whose is + /// null). Mirrors 's root exclusion. + /// + public static IReadOnlyList BuildSegments(SharpTreeNode? node) + { + var segments = new List(); + for (var current = node; current is { Parent: not null }; current = current.Parent) + segments.Add(new BreadcrumbSegment(current)); + segments.Reverse(); + return segments; + } + + /// + /// The child nodes of wrapped as crumbs, for the chevron + /// dropdown's sibling list. Materialises the node's lazy children on demand. + /// + public IReadOnlyList GetChildSegments(BreadcrumbSegment? segment) + { + if (segment?.Node == null) + return System.Array.Empty(); + segment.Node.EnsureLazyChildren(); + return segment.Node.Children.Select(child => new BreadcrumbSegment(child)).ToArray(); + } + + /// Moves the assembly-tree selection to 's node. + [RelayCommand] + public void NavigateSegment(BreadcrumbSegment? segment) + { + if (segment?.Node == null) + return; + AppComposition.TryGetExport()?.SelectNode(segment.Node); + } + + /// Switches the bar into search mode (e.g. Ctrl+L / clicking the bar). + [RelayCommand] + public void EnterSearch() => Mode = OmnibarMode.Search; + + /// Returns to the breadcrumb and clears the query. + [RelayCommand] + public void ExitSearch() + { + Mode = OmnibarMode.Breadcrumb; + SearchText = string.Empty; + } + + /// + /// Navigates to a search suggestion: resolves its reference to a tree node and moves the + /// selection there (or opens a new document tab when is + /// set). Mirrors . + /// + public void Activate(SearchResult? result, bool inNewTabPage = false) + { + if (result?.Reference == null) + return; + var atm = AppComposition.TryGetExport(); + if (atm == null) + return; + var node = atm.FindTreeNode(result.Reference); + if (node == null) + return; + if (inNewTabPage) + AppComposition.TryGetExport()?.OpenNodeInNewTab(node); + else + atm.SelectedItem = node; + } + + RunningSearch? currentSearch; + + void RestartSearch() + { + currentSearch?.Cancel(); + currentSearch = null; + searchSink.Clear(); + Suggestions.Clear(); + IsSearching = false; + + var term = SearchText ?? string.Empty; + if (string.IsNullOrWhiteSpace(term)) + return; + + var assemblyList = AppComposition.TryGetExport()?.AssemblyList; + if (assemblyList == null) + return; + var language = AppComposition.TryGetExport()?.CurrentLanguage; + if (language == null) + return; + var apiVisibility = AppComposition.TryGetExport()?.SessionSettings?.LanguageSettings?.ShowApiLevel + ?? ApiVisibility.PublicOnly; + + var run = new RunningSearch( + assemblyList.GetAssemblies(), + term, + SearchMode.TypeAndMember, + language, + apiVisibility, + new AvaloniaSearchResultFactory(language), + searchSink, + SearchResult.ComparerByFitness); + run.Completed += OnRunCompleted; + currentSearch = run; + IsSearching = true; + run.Start(); + } + + void OnRunCompleted(RunningSearch sender) + { + if (!ReferenceEquals(sender, currentSearch)) + return; + IsSearching = false; + } + + // Mirror only the head of the fitness-sorted sink into the bound Suggestions, updating at + // the edges so the dropdown changes in place rather than rebuilding on every streamed hit. + void OnSearchSinkChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add + when e.NewItems?.Count == 1 && e.NewStartingIndex < MaxSuggestions: + Suggestions.Insert(e.NewStartingIndex, (SearchResult)e.NewItems[0]!); + while (Suggestions.Count > MaxSuggestions) + Suggestions.RemoveAt(Suggestions.Count - 1); + break; + case NotifyCollectionChangedAction.Reset: + Suggestions.Clear(); + break; + } + } + } +} diff --git a/ILSpy/Options/DisplaySettingReactions.cs b/ILSpy/Options/DisplaySettingReactions.cs index b89ebf3ef..422aaf4d2 100644 --- a/ILSpy/Options/DisplaySettingReactions.cs +++ b/ILSpy/Options/DisplaySettingReactions.cs @@ -80,6 +80,8 @@ namespace ICSharpCode.ILSpy.Options [nameof(DisplaySettings.EnableWordWrap)] = DisplaySettingReaction.EditorLive, [nameof(DisplaySettings.HighlightCurrentLine)] = DisplaySettingReaction.EditorLive, [nameof(DisplaySettings.HighlightMatchingBraces)] = DisplaySettingReaction.EditorLive, + // The text view shows/hides its own omnibar from this; tree and output are unaffected. + [nameof(DisplaySettings.EnableOmnibar)] = DisplaySettingReaction.EditorLive, // No model-side reaction. [nameof(DisplaySettings.StyleWindowTitleBar)] = DisplaySettingReaction.None, diff --git a/ILSpy/Options/DisplaySettings.cs b/ILSpy/Options/DisplaySettings.cs index a0dbd0111..bfd7503d4 100644 --- a/ILSpy/Options/DisplaySettings.cs +++ b/ILSpy/Options/DisplaySettings.cs @@ -96,6 +96,9 @@ namespace ICSharpCode.ILSpy.Options [ObservableProperty] bool decodeCustomAttributeBlobs; + [ObservableProperty] + bool enableOmnibar; + public XName SectionName => "DisplaySettings"; public void LoadFromXml(XElement section) @@ -121,6 +124,7 @@ namespace ICSharpCode.ILSpy.Options ShowRawOffsetsAndBytesBeforeInstruction = (bool?)section.Attribute(nameof(ShowRawOffsetsAndBytesBeforeInstruction)) ?? false; StyleWindowTitleBar = (bool?)section.Attribute(nameof(StyleWindowTitleBar)) ?? false; DecodeCustomAttributeBlobs = (bool?)section.Attribute(nameof(DecodeCustomAttributeBlobs)) ?? false; + EnableOmnibar = (bool?)section.Attribute(nameof(EnableOmnibar)) ?? false; } public XElement SaveToXml() @@ -147,6 +151,7 @@ namespace ICSharpCode.ILSpy.Options section.SetAttributeValue(nameof(ShowRawOffsetsAndBytesBeforeInstruction), ShowRawOffsetsAndBytesBeforeInstruction); section.SetAttributeValue(nameof(StyleWindowTitleBar), StyleWindowTitleBar); section.SetAttributeValue(nameof(DecodeCustomAttributeBlobs), DecodeCustomAttributeBlobs); + section.SetAttributeValue(nameof(EnableOmnibar), EnableOmnibar); return section; } } diff --git a/ILSpy/Options/DisplaySettingsPanel.axaml b/ILSpy/Options/DisplaySettingsPanel.axaml index 0156e8019..9d9190a60 100644 --- a/ILSpy/Options/DisplaySettingsPanel.axaml +++ b/ILSpy/Options/DisplaySettingsPanel.axaml @@ -103,6 +103,8 @@ Content="Enable multiple rows in tab strip" /> + diff --git a/ILSpy/Properties/Resources.Designer.cs b/ILSpy/Properties/Resources.Designer.cs index 678d613d0..6285f90e9 100644 --- a/ILSpy/Properties/Resources.Designer.cs +++ b/ILSpy/Properties/Resources.Designer.cs @@ -2997,7 +2997,16 @@ namespace ICSharpCode.ILSpy.Properties { return ResourceManager.GetString("ShowMetadataTokensInBase10", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Show omnibar (breadcrumb / search bar) above the decompiled code. + /// + public static string ShowOmnibar { + get { + return ResourceManager.GetString("ShowOmnibar", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show only public types and members. /// diff --git a/ILSpy/Properties/Resources.resx b/ILSpy/Properties/Resources.resx index 256beeab2..857418494 100644 --- a/ILSpy/Properties/Resources.resx +++ b/ILSpy/Properties/Resources.resx @@ -1021,6 +1021,9 @@ Do you want to continue? Show metadata tokens in base 10 + + Show omnibar (breadcrumb / search bar) above the decompiled code + Show only public types and members diff --git a/ILSpy/TextView/DecompilerTabPageModel.cs b/ILSpy/TextView/DecompilerTabPageModel.cs index 4eff012cd..bfd487e5e 100644 --- a/ILSpy/TextView/DecompilerTabPageModel.cs +++ b/ILSpy/TextView/DecompilerTabPageModel.cs @@ -307,6 +307,10 @@ namespace ICSharpCode.ILSpy.TextView cachedBaseTitle = ComposeBaseTitle(); foreach (var n in currentNodes) n.PropertyChanged += OnCurrentNodePropertyChanged; + // Let host chrome (the omnibar breadcrumb) react to the tab re-targeting a node. + // Nothing else relied on the absence of this notification. + OnPropertyChanged(nameof(CurrentNodes)); + OnPropertyChanged(nameof(CurrentNode)); StartDecompile(); } } diff --git a/ILSpy/TextView/DecompilerTextView.axaml b/ILSpy/TextView/DecompilerTextView.axaml index c0130d9d7..71c2462f0 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml +++ b/ILSpy/TextView/DecompilerTextView.axaml @@ -5,6 +5,7 @@ xmlns:ae="using:AvaloniaEdit" xmlns:aeediting="using:AvaloniaEdit.Editing" xmlns:textView="using:ICSharpCode.ILSpy.TextView" + xmlns:omnibar="using:ICSharpCode.ILSpy.Controls.Omnibar" mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400" Name="self" x:Class="ICSharpCode.ILSpy.TextView.DecompilerTextView" @@ -20,6 +21,11 @@ + + + + @@ -87,4 +93,5 @@ materialises and the popup is invisible. Renders nothing while closed. --> + diff --git a/ILSpy/TextView/DecompilerTextView.axaml.cs b/ILSpy/TextView/DecompilerTextView.axaml.cs index 1e01f38de..693a0777e 100644 --- a/ILSpy/TextView/DecompilerTextView.axaml.cs +++ b/ILSpy/TextView/DecompilerTextView.axaml.cs @@ -129,6 +129,19 @@ namespace ICSharpCode.ILSpy.TextView Editor.TextArea.Caret.PositionChanged += OnCaretPositionChanged; SetupZoomAndCopy(); + + // Ctrl+L focuses the omnibar into search mode (browser address-bar gesture). Tunnel so + // it wins before AvaloniaEdit's own key handling while focus is anywhere in the editor. + AddHandler(KeyDownEvent, OnPreviewKeyDownForOmnibar, RoutingStrategies.Tunnel); + } + + void OnPreviewKeyDownForOmnibar(object? sender, KeyEventArgs e) + { + if (e.Key == Key.L && e.KeyModifiers == KeyModifiers.Control && Omnibar.IsVisible) + { + Omnibar.FocusSearch(); + e.Handled = true; + } } // One generator lives for the lifetime of the view; we only swap its References collection @@ -492,6 +505,7 @@ namespace ICSharpCode.ILSpy.TextView ApplyDisplaySetting(s, nameof(DisplaySettings.HighlightCurrentLine)); ApplyDisplaySetting(s, nameof(DisplaySettings.IndentationSize)); ApplyDisplaySetting(s, nameof(DisplaySettings.IndentationUseTabs)); + ApplyDisplaySetting(s, nameof(DisplaySettings.EnableOmnibar)); } void ApplyDisplaySetting(DisplaySettings s, string? propertyName) @@ -522,6 +536,11 @@ namespace ICSharpCode.ILSpy.TextView case nameof(DisplaySettings.IndentationUseTabs): Editor.Options.ConvertTabsToSpaces = !s.IndentationUseTabs; break; + case nameof(DisplaySettings.EnableOmnibar): + // Off by default; the Options Display "Tab options" toggle shows/hides the bar + // live without a re-decompile, per text view. + Omnibar.IsVisible = s.EnableOmnibar; + break; } } @@ -605,6 +624,13 @@ namespace ICSharpCode.ILSpy.TextView // the first attach and an ABA reattach. model.CaptureViewState = GetCurrentViewState; ApplyDocument(model); + // Point the breadcrumb at this tab's node (the bar owns its own VM, so feed it the + // node rather than letting it inherit the document DataContext). + Omnibar.SetNode(model.CurrentNode); + } + else + { + Omnibar.SetNode(null); } } @@ -637,6 +663,13 @@ namespace ICSharpCode.ILSpy.TextView // SyntaxExtension fires an intermediate rebuild that must not move the scroll. ApplyDocument(model, restoreViewState: e.PropertyName == nameof(DecompilerTabPageModel.Text)); } + // Keep the breadcrumb in step when the tab re-targets a different node (the model + // raises this when CurrentNodes changes). + else if (sender is DecompilerTabPageModel nodeModel + && e.PropertyName == nameof(DecompilerTabPageModel.CurrentNode)) + { + Omnibar.SetNode(nodeModel.CurrentNode); + } // HighlightedReference can change independently of a re-decompile (e.g. the analyzer // pane sets it while the user is already on the right tab). Apply it directly without // rebuilding the document.