diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs index cd2143200a..569d0c541f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ContextMenuViewModel.cs @@ -107,11 +107,11 @@ public partial class ContextMenuViewModel : ObservableObject, return 0; } - var nameMatch = StringMatcher.FuzzySearch(query, item.Title); + var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title); - var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); + var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max(); } /// diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs index 43dc24f72f..6547339ca1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs @@ -5,7 +5,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.CmdPal.Core.ViewModels.Models; using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; @@ -109,8 +108,6 @@ public partial class ListItemViewModel(IListItem model, WeakReference StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success; - public override string ToString() => $"{Name} ListItemViewModel"; public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model); @@ -132,7 +129,7 @@ public partial class ListItemViewModel(IListItem model, WeakReference removedItems = []; lock (_listLock) @@ -264,13 +283,7 @@ public partial class ListViewModel : PageViewModel, IDisposable _initializeItemsTask = new Task(() => { - try - { - InitializeItemsTask(_cancellationTokenSource.Token); - } - catch (OperationCanceledException) - { - } + InitializeItemsTask(_cancellationTokenSource.Token); }); _initializeItemsTask.Start(); @@ -304,7 +317,10 @@ public partial class ListViewModel : PageViewModel, IDisposable private void InitializeItemsTask(CancellationToken ct) { // Were we already canceled? - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + return; + } ListItemViewModel[] iterable; lock (_listLock) @@ -314,7 +330,10 @@ public partial class ListViewModel : PageViewModel, IDisposable foreach (var item in iterable) { - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + return; + } // TODO: GH #502 // We should probably remove the item from the list if it @@ -323,7 +342,10 @@ public partial class ListViewModel : PageViewModel, IDisposable // at once. item.SafeInitializeProperties(); - ct.ThrowIfCancellationRequested(); + if (ct.IsCancellationRequested) + { + return; + } } } @@ -345,9 +367,9 @@ public partial class ListViewModel : PageViewModel, IDisposable return 1; } - var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); - var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title); + var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle); + return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max(); } private struct ScoredListItemViewModel diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 2dfb934f1c..6b0fa8c1f0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Collections.Specialized; +using System.Diagnostics; using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; using Microsoft.CmdPal.Common.Helpers; @@ -22,20 +23,27 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage; /// public partial class MainListPage : DynamicListPage, IRecipient, - IRecipient + IRecipient, IDisposable { - private readonly IServiceProvider _serviceProvider; + private readonly string[] _specialFallbacks = [ + "com.microsoft.cmdpal.builtin.run", + "com.microsoft.cmdpal.builtin.calculator" + ]; + private readonly IServiceProvider _serviceProvider; private readonly TopLevelCommandManager _tlcManager; private IEnumerable>? _filteredItems; private IEnumerable>? _filteredApps; - private IEnumerable? _allApps; + private IEnumerable>? _fallbackItems; private bool _includeApps; private bool _filteredItemsIncludesApps; + private int _appResultLimit = 10; private InterlockedBoolean _refreshRunning; private InterlockedBoolean _refreshRequested; + private CancellationTokenSource? _cancellationTokenSource; + public MainListPage(IServiceProvider serviceProvider) { Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png"); @@ -50,12 +58,12 @@ public partial class MainListPage : DynamicListPage, // We just want to know when it is done. var allApps = AllAppsCommandProvider.Page; allApps.PropChanged += (s, p) => - { - if (p.PropertyName == nameof(allApps.IsLoading)) { - IsLoading = ActuallyLoading(); - } - }; + if (p.PropertyName == nameof(allApps.IsLoading)) + { + IsLoading = ActuallyLoading(); + } + }; WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -150,10 +158,23 @@ public partial class MainListPage : DynamicListPage, { lock (_tlcManager.TopLevelCommands) { + IEnumerable> limitedApps = Enumerable.Empty>(); + + // Fuzzy matching can produce a lot of results, so we want to limit the + // number of apps we show at once if it's a large set. + if (_filteredApps?.Any() == true) + { + limitedApps = _filteredApps.OrderByDescending(s => s.Score).Take(_appResultLimit); + } + var items = Enumerable.Empty>() .Concat(_filteredItems is not null ? _filteredItems : []) - .Concat(_filteredApps is not null ? _filteredApps : []) + .Concat(limitedApps) .OrderByDescending(o => o.Score) + + // Add fallback items post-sort so they are always at the end of the list + // and eventually ordered based on user preference + .Concat(_fallbackItems is not null ? _fallbackItems.Where(w => !string.IsNullOrEmpty(w.Item.Title)) : []) .Select(s => s.Item) .ToArray(); return items; @@ -163,10 +184,29 @@ public partial class MainListPage : DynamicListPage, public override void UpdateSearchText(string oldSearch, string newSearch) { + var timer = new Stopwatch(); + timer.Start(); + + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = new CancellationTokenSource(); + + var token = _cancellationTokenSource.Token; + if (token.IsCancellationRequested) + { + return; + } + // Handle changes to the filter text here if (!string.IsNullOrEmpty(SearchText)) { var aliases = _serviceProvider.GetService()!; + + if (token.IsCancellationRequested) + { + return; + } + if (aliases.CheckAlias(newSearch)) { if (_filteredItemsIncludesApps != _includeApps) @@ -176,7 +216,6 @@ public partial class MainListPage : DynamicListPage, _filteredItemsIncludesApps = _includeApps; _filteredItems = null; _filteredApps = null; - _allApps = null; } } @@ -184,10 +223,20 @@ public partial class MainListPage : DynamicListPage, } } + if (token.IsCancellationRequested) + { + return; + } + var commands = _tlcManager.TopLevelCommands; lock (commands) { - UpdateFallbacks(newSearch, commands.ToImmutableArray()); + UpdateFallbacks(SearchText, commands.ToImmutableArray(), token); + + if (token.IsCancellationRequested) + { + return; + } // Cleared out the filter text? easy. Reset _filteredItems, and bail out. if (string.IsNullOrEmpty(newSearch)) @@ -195,7 +244,7 @@ public partial class MainListPage : DynamicListPage, _filteredItemsIncludesApps = _includeApps; _filteredItems = null; _filteredApps = null; - _allApps = null; + _fallbackItems = null; RaiseItemsChanged(commands.Count); return; } @@ -206,7 +255,7 @@ public partial class MainListPage : DynamicListPage, { _filteredItems = null; _filteredApps = null; - _allApps = null; + _fallbackItems = null; } // If the internal state has changed, reset _filteredItems to reset the list. @@ -214,61 +263,149 @@ public partial class MainListPage : DynamicListPage, { _filteredItems = null; _filteredApps = null; - _allApps = null; + _fallbackItems = null; } - var newFilteredItems = _filteredItems?.Select(s => s.Item); + if (token.IsCancellationRequested) + { + return; + } + + IEnumerable newFilteredItems = Enumerable.Empty(); + IEnumerable newFallbacks = Enumerable.Empty(); + IEnumerable newApps = Enumerable.Empty(); + + if (_filteredItems is not null) + { + newFilteredItems = _filteredItems.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } + + if (_filteredApps is not null) + { + newApps = _filteredApps.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } + + if (_fallbackItems is not null) + { + newFallbacks = _fallbackItems.Select(s => s.Item); + } + + if (token.IsCancellationRequested) + { + return; + } // If we don't have any previous filter results to work with, start // with a list of all our commands & apps. - if (newFilteredItems is null && _filteredApps is null) + if (!newFilteredItems.Any() && !newApps.Any()) { - newFilteredItems = commands; + // We're going to start over with our fallbacks + newFallbacks = Enumerable.Empty(); + + newFilteredItems = commands.Where(s => !s.IsFallback || _specialFallbacks.Contains(s.CommandProviderId)); + + // Fallbacks are always included in the list, even if they + // don't match the search text. But we don't want to + // consider them when filtering the list. + newFallbacks = commands.Where(s => s.IsFallback && !_specialFallbacks.Contains(s.CommandProviderId)); + + if (token.IsCancellationRequested) + { + return; + } + _filteredItemsIncludesApps = _includeApps; if (_includeApps) { - _allApps = AllAppsCommandProvider.Page.GetItems(); + newApps = AllAppsCommandProvider.Page.GetItems().ToList(); } } + if (token.IsCancellationRequested) + { + return; + } + + if (token.IsCancellationRequested) + { + return; + } + // Produce a list of everything that matches the current filter. _filteredItems = ListHelpers.FilterListWithScores(newFilteredItems ?? [], SearchText, ScoreTopLevelItem); - // Produce a list of filtered apps with the appropriate limit - if (_allApps is not null) - { - _filteredApps = ListHelpers.FilterListWithScores(_allApps, SearchText, ScoreTopLevelItem); + // Defaulting scored to 1 but we'll eventually use user rankings + _fallbackItems = newFallbacks.Select(f => new Scored { Item = f, Score = 1 }); - var appResultLimit = AllAppsCommandProvider.TopLevelResultLimit; - if (appResultLimit >= 0) + if (token.IsCancellationRequested) + { + return; + } + + // Produce a list of filtered apps with the appropriate limit + if (newApps.Any()) + { + var scoredApps = ListHelpers.FilterListWithScores(newApps, SearchText, ScoreTopLevelItem); + + if (token.IsCancellationRequested) { - _filteredApps = _filteredApps.Take(appResultLimit); + return; } + + // We'll apply this limit in the GetItems method after merging with commands + // but we need to know the limit now to avoid re-scoring apps + var appLimit = AllAppsCommandProvider.TopLevelResultLimit; + + _filteredApps = scoredApps; } RaiseItemsChanged(); + + timer.Stop(); + Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms"); } } - private void UpdateFallbacks(string newSearch, IReadOnlyList commands) + private void UpdateFallbacks(string newSearch, IReadOnlyList commands, CancellationToken token) { - // fire and forget - _ = Task.Run(() => + _ = Task.Run( + () => { var needsToUpdate = false; foreach (var command in commands) { + if (token.IsCancellationRequested) + { + return; + } + var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch); needsToUpdate = needsToUpdate || changedVisibility; } if (needsToUpdate) { + if (token.IsCancellationRequested) + { + return; + } + RaiseItemsChanged(); } - }); + }, + token); } private bool ActuallyLoading() @@ -322,19 +459,19 @@ public partial class MainListPage : DynamicListPage, // * otherwise full weight match var nameMatch = isWhiteSpace ? (title.Contains(query) ? 1 : 0) : - StringMatcher.FuzzySearch(query, title).Score; + FuzzyStringMatcher.ScoreFuzzy(query, title); // Subtitle: // * whitespace query: 1/2 point // * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer var descriptionMatch = isWhiteSpace ? (topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) : - (StringMatcher.FuzzySearch(query, topLevelOrAppItem.Subtitle).Score - 4) / 2.0; + (FuzzyStringMatcher.ScoreFuzzy(query, topLevelOrAppItem.Subtitle) - 4) / 2.0; // Extension title: despite not being visible, give the extension name itself some weight // * whitespace query: 0 points // * otherwise more weight than a subtitle, but not much - var extensionTitleMatch = isWhiteSpace ? 0 : StringMatcher.FuzzySearch(query, extensionDisplayName).Score / 1.5; + var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5; var scores = new[] { @@ -397,4 +534,22 @@ public partial class MainListPage : DynamicListPage, private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender); private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails; + + public void Dispose() + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + + _tlcManager.PropertyChanged -= TlcManager_PropertyChanged; + _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged; + + var settings = _serviceProvider.GetService(); + if (settings is not null) + { + settings.SettingsChanged -= SettingsChangedHandler; + } + + WeakReferenceMessenger.Default.UnregisterAll(this); + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs index 8671f90f81..70bfffe6b3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TypedEventHandlerExtensions.cs @@ -55,7 +55,10 @@ public static class TypedEventHandlerExtensions .OfType>() .Select(invocationDelegate => { - cancellationToken.ThrowIfCancellationRequested(); + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } invocationDelegate(sender, eventArgs); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs index a4da29e830..29a32784ad 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.UnitTestsBase/CommandPaletteUnitTestBase.cs @@ -2,11 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System; -using System.Collections.Generic; using System.Linq; -using System.Text; -using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -14,7 +10,9 @@ namespace Microsoft.CmdPal.Ext.UnitTestBase; public class CommandPaletteUnitTestBase { - private bool MatchesFilter(string filter, IListItem item) => StringMatcher.FuzzySearch(filter, item.Title).Success || StringMatcher.FuzzySearch(filter, item.Subtitle).Success; + private bool MatchesFilter(string filter, IListItem item) => + FuzzyStringMatcher.ScoreFuzzy(filter, item.Title) > 0 || + FuzzyStringMatcher.ScoreFuzzy(filter, item.Subtitle) > 0; public IListItem[] Query(string query, IListItem[] candidates) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs index 84d915f540..3329456960 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsCommandProvider.cs @@ -51,21 +51,21 @@ public partial class AllAppsCommandProvider : CommandProvider if (limitSetting is null) { - return -1; + return 10; } - var quantity = -1; + var quantity = 10; if (int.TryParse(limitSetting, out var result)) { - quantity = result; + quantity = result < 0 ? quantity : result; } return quantity; } } - public override ICommandItem[] TopLevelCommands() => [_listItem, .._page.GetPinnedApps()]; + public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()]; public ICommandItem? LookupApp(string displayName) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs index 320501fcdc..bf326221f9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/AllAppsSettings.cs @@ -18,16 +18,12 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; - private static string Experimental(string propertyName) => $"{_namespace}.experimental.{propertyName}"; - private static readonly List _searchResultLimitChoices = [ - new ChoiceSetSetting.Choice(Resources.limit_none, "-1"), new ChoiceSetSetting.Choice(Resources.limit_0, "0"), new ChoiceSetSetting.Choice(Resources.limit_1, "1"), new ChoiceSetSetting.Choice(Resources.limit_5, "5"), new ChoiceSetSetting.Choice(Resources.limit_10, "10"), - new ChoiceSetSetting.Choice(Resources.limit_20, "20"), ]; #pragma warning disable SA1401 // Fields should be private diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs index cdf0ccfa47..1cb0c57f28 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Calc/CalculatorCommandProvider.cs @@ -23,7 +23,7 @@ public partial class CalculatorCommandProvider : CommandProvider public CalculatorCommandProvider() { - Id = "Calculator"; + Id = "com.microsoft.cmdpal.builtin.calculator"; DisplayName = Resources.calculator_display_name; Icon = Icons.CalculatorIcon; Settings = ((SettingsManager)settings).Settings; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs index fde17ba14c..06fcf7025b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/Pages/ShellListPage.cs @@ -263,7 +263,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable var filterHistory = (string query, KeyValuePair pair) => { // Fuzzy search on the key (command string) - var score = StringMatcher.FuzzySearch(query, pair.Key).Score; + var score = FuzzyStringMatcher.ScoreFuzzy(query, pair.Key); return score; }; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs index a4bbeec5ea..8893486464 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Shell/ShellCommandsProvider.cs @@ -24,7 +24,7 @@ public partial class ShellCommandsProvider : CommandProvider { _historyService = runHistoryService; - Id = "Run"; + Id = "com.microsoft.cmdpal.builtin.run"; DisplayName = Resources.cmd_plugin_name; Icon = Icons.RunV2Icon; Settings = _settingsManager.Settings; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs index d97d352559..adaa9f7c26 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/FallbackSystemCommandItem.cs @@ -47,8 +47,8 @@ internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem { var title = command.Title; var subTitle = command.Subtitle; - var titleScore = StringMatcher.FuzzySearch(query, title).Score; - var subTitleScore = StringMatcher.FuzzySearch(query, subTitle).Score; + var titleScore = FuzzyStringMatcher.ScoreFuzzy(query, title); + var subTitleScore = FuzzyStringMatcher.ScoreFuzzy(query, subTitle); var maxScore = Math.Max(titleScore, subTitleScore); if (maxScore > resultScore) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs index 3f54ca8438..6938875f80 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.TimeDate/Helpers/AvailableResult.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Runtime.CompilerServices; using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Ext.TimeDate.Helpers; @@ -64,12 +63,12 @@ internal sealed class AvailableResult public int Score(string query, string label, string tags) { // Get match for label (or for tags if label score is <1) - var score = StringMatcher.FuzzySearch(query, label).Score; + var score = FuzzyStringMatcher.ScoreFuzzy(query, label); if (score < 1) { foreach (var t in tags.Split(";")) { - var tagScore = StringMatcher.FuzzySearch(query, t.Trim()).Score / 2; + var tagScore = FuzzyStringMatcher.ScoreFuzzy(query, t.Trim()) / 2; if (tagScore > score) { score = tagScore / 2; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs new file mode 100644 index 0000000000..f4591bc443 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/FuzzyStringMatcher.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CommandPalette.Extensions.Toolkit; + +// Inspired by the fuzzy.rs from edit.exe +public static class FuzzyStringMatcher +{ + private const int NOMATCH = 0; + + public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true) + { + var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches); + return s; + } + + public static (int Score, List Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches) + { + if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle)) + { + return (NOMATCH, new List()); + } + + var target = haystack.ToCharArray(); + var query = needle.ToCharArray(); + + if (target.Length < query.Length) + { + return (NOMATCH, new List()); + } + + var targetUpper = FoldCase(haystack); + var queryUpper = FoldCase(needle); + var targetUpperChars = targetUpper.ToCharArray(); + var queryUpperChars = queryUpper.ToCharArray(); + + var area = query.Length * target.Length; + var scores = new int[area]; + var matches = new int[area]; + + for (var qi = 0; qi < query.Length; qi++) + { + var qiOffset = qi * target.Length; + var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0; + + for (var ti = 0; ti < target.Length; ti++) + { + var currentIndex = qiOffset + ti; + var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0; + var leftScore = ti > 0 ? scores[currentIndex - 1] : 0; + var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0; + var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0; + + var score = (diagScore == 0 && qi != 0) ? 0 : + ComputeCharScore( + query[qi], + queryUpperChars[qi], + ti != 0 ? target[ti - 1] : null, + target[ti], + targetUpperChars[ti], + matchSeqLen); + + var isValidScore = score != 0 && diagScore + score >= leftScore && + (allowNonContiguousMatches || qi > 0 || + targetUpperChars.Skip(ti).Take(queryUpperChars.Length).SequenceEqual(queryUpperChars)); + + if (isValidScore) + { + matches[currentIndex] = matchSeqLen + 1; + scores[currentIndex] = diagScore + score; + } + else + { + matches[currentIndex] = NOMATCH; + scores[currentIndex] = leftScore; + } + } + } + + var positions = new List(); + if (query.Length > 0 && target.Length > 0) + { + var qi = query.Length - 1; + var ti = target.Length - 1; + + while (true) + { + var index = (qi * target.Length) + ti; + if (matches[index] == NOMATCH) + { + if (ti == 0) + { + break; + } + + ti--; + } + else + { + positions.Add(ti); + if (qi == 0 || ti == 0) + { + break; + } + + qi--; + ti--; + } + } + + positions.Reverse(); + } + + return (scores[area - 1], positions); + } + + private static string FoldCase(string input) + { + return input.ToUpperInvariant(); + } + + private static int ComputeCharScore( + char query, + char queryLower, + char? targetPrev, + char targetCurr, + char targetLower, + int matchSeqLen) + { + if (!ConsiderAsEqual(queryLower, targetLower)) + { + return 0; + } + + var score = 1; // Character match bonus + + if (matchSeqLen > 0) + { + score += matchSeqLen * 5; // Consecutive match bonus + } + + if (query == targetCurr) + { + score += 1; // Same case bonus + } + + if (targetPrev.HasValue) + { + var sepBonus = ScoreSeparator(targetPrev.Value); + if (sepBonus > 0) + { + score += sepBonus; + } + else if (char.IsUpper(targetCurr) && matchSeqLen == 0) + { + score += 2; // CamelCase bonus + } + } + else + { + score += 8; // Start of word bonus + } + + return score; + } + + private static bool ConsiderAsEqual(char a, char b) + { + return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/'); + } + + private static int ScoreSeparator(char ch) + { + return ch switch + { + '/' or '\\' => 5, + '_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4, + _ => 0, + }; + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs index 441de9c713..3847ab8e55 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListHelpers.cs @@ -19,17 +19,17 @@ public partial class ListHelpers return 0; } - var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); + var nameMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title); // var locNameMatch = StringMatcher.FuzzySearch(query, NameLocalized); - var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); + var descriptionMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle); // var executableNameMatch = StringMatcher.FuzzySearch(query, ExePath); // var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized); // var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName); // var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized); // var score = new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, executableNameMatch.Score }.Max(); - return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + return new[] { nameMatchScore, (descriptionMatchScore - 4) / 2, 0 }.Max(); } public static IEnumerable FilterList(IEnumerable items, string query) diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs deleted file mode 100644 index 6d9009661a..0000000000 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/StringMatcher.cs +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System.Globalization; - -namespace Microsoft.CommandPalette.Extensions.Toolkit; - -public partial class StringMatcher -{ - private readonly MatchOption _defaultMatchOption = new(); - - public SearchPrecisionScore UserSettingSearchPrecision { get; set; } - - // private readonly IAlphabet _alphabet; - public StringMatcher(/*IAlphabet alphabet = null*/) - { - // _alphabet = alphabet; - } - - private static StringMatcher? _instance; - - public static StringMatcher Instance - { - get - { - _instance ??= new StringMatcher(); - - return _instance; - } - set => _instance = value; - } - - private static readonly char[] Separator = new[] { ' ' }; - - public static MatchResult FuzzySearch(string query, string stringToCompare) - { - return Instance.FuzzyMatch(query, stringToCompare); - } - - public MatchResult FuzzyMatch(string query, string stringToCompare) - { - try - { - return FuzzyMatch(query, stringToCompare, _defaultMatchOption); - } - catch (IndexOutOfRangeException) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - } - - /// - /// Current method: - /// Character matching + substring matching; - /// 1. Query search string is split into substrings, separator is whitespace. - /// 2. Check each query substring's characters against full compare string, - /// 3. if a character in the substring is matched, loop back to verify the previous character. - /// 4. If previous character also matches, and is the start of the substring, update list. - /// 5. Once the previous character is verified, move on to the next character in the query substring. - /// 6. Move onto the next substring's characters until all substrings are checked. - /// 7. Consider success and move onto scoring if every char or substring without whitespaces matched - /// - public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt) - { - if (string.IsNullOrEmpty(stringToCompare)) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - - var bestResult = new MatchResult(false, UserSettingSearchPrecision); - - for (var startIndex = 0; startIndex < stringToCompare.Length; startIndex++) - { - MatchResult result = FuzzyMatch(query, stringToCompare, opt, startIndex); - if (result.Success && (!bestResult.Success || result.Score > bestResult.Score)) - { - bestResult = result; - } - } - - return bestResult; - } - - private MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt, int startIndex) - { - if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) - { - return new MatchResult(false, UserSettingSearchPrecision); - } - - ArgumentNullException.ThrowIfNull(opt); - - query = query.Trim(); - - // if (_alphabet is not null) - // { - // query = _alphabet.Translate(query); - // stringToCompare = _alphabet.Translate(stringToCompare); - // } - - // Using InvariantCulture since this is internal - var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToUpper(CultureInfo.InvariantCulture) : stringToCompare; - var queryWithoutCase = opt.IgnoreCase ? query.ToUpper(CultureInfo.InvariantCulture) : query; - - var querySubstrings = queryWithoutCase.Split(Separator, StringSplitOptions.RemoveEmptyEntries); - var currentQuerySubstringIndex = 0; - var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; - var currentQuerySubstringCharacterIndex = 0; - - var firstMatchIndex = -1; - var firstMatchIndexInWord = -1; - var lastMatchIndex = 0; - var allQuerySubstringsMatched = false; - var matchFoundInPreviousLoop = false; - var allSubstringsContainedInCompareString = true; - - var indexList = new List(); - List spaceIndices = new List(); - - for (var compareStringIndex = startIndex; compareStringIndex < fullStringToCompareWithoutCase.Length; compareStringIndex++) - { - // To maintain a list of indices which correspond to spaces in the string to compare - // To populate the list only for the first query substring - if (fullStringToCompareWithoutCase[compareStringIndex].Equals(' ') && currentQuerySubstringIndex == 0) - { - spaceIndices.Add(compareStringIndex); - } - - bool compareResult; - if (opt.IgnoreCase) - { - var fullStringToCompare = fullStringToCompareWithoutCase[compareStringIndex].ToString(); - var querySubstring = currentQuerySubstring[currentQuerySubstringCharacterIndex].ToString(); -#pragma warning disable CA1309 // Use ordinal string comparison (We are looking for a fuzzy match here) - compareResult = string.Compare(fullStringToCompare, querySubstring, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace) != 0; -#pragma warning restore CA1309 // Use ordinal string comparison - } - else - { - compareResult = fullStringToCompareWithoutCase[compareStringIndex] != currentQuerySubstring[currentQuerySubstringCharacterIndex]; - } - - if (compareResult) - { - matchFoundInPreviousLoop = false; - continue; - } - - if (firstMatchIndex < 0) - { - // first matched char will become the start of the compared string - firstMatchIndex = compareStringIndex; - } - - if (currentQuerySubstringCharacterIndex == 0) - { - // first letter of current word - matchFoundInPreviousLoop = true; - firstMatchIndexInWord = compareStringIndex; - } - else if (!matchFoundInPreviousLoop) - { - // we want to verify that there is not a better match if this is not a full word - // in order to do so we need to verify all previous chars are part of the pattern - var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex; - - if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex, fullStringToCompareWithoutCase, currentQuerySubstring)) - { - matchFoundInPreviousLoop = true; - - // if it's the beginning character of the first query substring that is matched then we need to update start index - firstMatchIndex = currentQuerySubstringIndex == 0 ? startIndexToVerify : firstMatchIndex; - - indexList = GetUpdatedIndexList(startIndexToVerify, currentQuerySubstringCharacterIndex, firstMatchIndexInWord, indexList); - } - } - - lastMatchIndex = compareStringIndex + 1; - indexList.Add(compareStringIndex); - - currentQuerySubstringCharacterIndex++; - - // if finished looping through every character in the current substring - if (currentQuerySubstringCharacterIndex == currentQuerySubstring.Length) - { - // if any of the substrings was not matched then consider as all are not matched - allSubstringsContainedInCompareString = matchFoundInPreviousLoop && allSubstringsContainedInCompareString; - - currentQuerySubstringIndex++; - - allQuerySubstringsMatched = AllQuerySubstringsMatched(currentQuerySubstringIndex, querySubstrings.Length); - if (allQuerySubstringsMatched) - { - break; - } - - // otherwise move to the next query substring - currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; - currentQuerySubstringCharacterIndex = 0; - } - } - - // proceed to calculate score if every char or substring without whitespaces matched - if (allQuerySubstringsMatched) - { - var nearestSpaceIndex = CalculateClosestSpaceIndex(spaceIndices, firstMatchIndex); - var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString); - - return new MatchResult(true, UserSettingSearchPrecision, indexList, score); - } - - return new MatchResult(false, UserSettingSearchPrecision); - } - - // To get the index of the closest space which precedes the first matching index - private static int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) - { - if (spaceIndices.Count == 0) - { - return -1; - } - else - { - return spaceIndices.OrderBy(item => (firstMatchIndex - item)).Where(item => firstMatchIndex > item).FirstOrDefault(-1); - } - } - - private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex, string fullStringToCompareWithoutCase, string currentQuerySubstring) - { - var allMatch = true; - for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) - { - if (fullStringToCompareWithoutCase[startIndexToVerify + indexToCheck] != - currentQuerySubstring[indexToCheck]) - { - allMatch = false; - } - } - - return allMatch; - } - - private static List GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, int firstMatchIndexInWord, List indexList) - { - var updatedList = new List(); - - indexList.RemoveAll(x => x >= firstMatchIndexInWord); - - updatedList.AddRange(indexList); - - for (var indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) - { - updatedList.Add(startIndexToVerify + indexToCheck); - } - - return updatedList; - } - - private static bool AllQuerySubstringsMatched(int currentQuerySubstringIndex, int querySubstringsLength) - { - return currentQuerySubstringIndex >= querySubstringsLength; - } - - private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, int matchLen, bool allSubstringsContainedInCompareString) - { - // A match found near the beginning of a string is scored more than a match found near the end - // A match is scored more if the characters in the patterns are closer to each other, - // while the score is lower if they are more spread out - - // The length of the match is assigned a larger weight factor. - // I.e. the length is more important than the location where a match is found. - const int matchLenWeightFactor = 2; - - var score = 100 * (query.Length + 1) * matchLenWeightFactor / ((1 + firstIndex) + (matchLenWeightFactor * (matchLen + 1))); - - // A match with less characters assigning more weights - if (stringToCompare.Length - query.Length < 5) - { - score += 20; - } - else if (stringToCompare.Length - query.Length < 10) - { - score += 10; - } - - if (allSubstringsContainedInCompareString) - { - var count = query.Count(c => !char.IsWhiteSpace(c)); - var threshold = 4; - if (count <= threshold) - { - score += count * 10; - } - else - { - score += (threshold * 10) + ((count - threshold) * 5); - } - } - -#pragma warning disable CA1309 // Use ordinal string comparison (Using CurrentCultureIgnoreCase since this relates to queries input by user) - if (string.Equals(query, stringToCompare, StringComparison.CurrentCultureIgnoreCase)) - { - var bonusForExactMatch = 10; - score += bonusForExactMatch; - } -#pragma warning restore CA1309 // Use ordinal string comparison - - return score; - } -}