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;
- }
-}