mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
CmdPal go brrrr (performance improvements) (#41959)
Still a WIP, but here's the deets so far: ## No more throwing canceled tokens Throwing exceptions is expensive and since we essentially cancel tokens anytime someone is typing beyond the debounce, we could be throwing exceptions a ton during search. Since we don't care about those past executions, now they just `return`. ## Reduced number of apps returned in search While users can specify how many apps (no limit, 1, 5), if they specify no limit, we hard limit it at 10. For a few reasons, fuzzy search gets _really_ fuzzy sometimes and gives answers that users would think is just plain wrong and they make the response list longer than it needs to be. ## Fuzzy search: still fuzzy, but faster Replaced `StringMatcher` class with `FuzzyStringMatcher`. `FuzzyStringMatcher` is a C# port by @zadjii-msft of the Rust port by @lhecker for [microsoft/edit](https://github.com/microsoft/edit), which I believe originally came from [VS Code](https://github.com/microsoft/vscode). It's a whole fuzzy rabbit hole. But it's faster than the `StringMatcher` class it replaced. ## Fallbacks, you need to fall back "In the beginning, fallbacks were created. This had made many people very angry and has been widely regarded as a bad move." Hitchhiker's Guide to the Galaxy jokes aside, fallbacks are one cause of slower search results. A few modifications have been made to get them out of the way without reverting their ability to do things dynamically. 1. Fallbacks are no longer scored and will always* appear at the bottom of the search results 2. In updating their search text, we now use a cancellation token to stop processing previous searches when a new keypress is recorded. ## * But Calculator & Run are special So, remember when I said that all fallbacks will not be ranked and always display at the bottom of the results? Surprise, some will be ranked and displayed based on that score. Specifically, Calculator and Run are fallbacks that are whitelisted from the restrictions mentioned above. They will continue to act as they do today. We do have the ability to add future fallbacks to that whitelist as well. --- ## Current preview Updated: 2025-09-24 https://github.com/user-attachments/assets/c74c9a8e-e438-4101-840b-1408d2acaefd --- Closes #39763 Closes #39239 Closes #39948 Closes #38594 Closes #40330
This commit is contained in:
@@ -107,11 +107,11 @@ public partial class ContextMenuViewModel : ObservableObject,
|
|||||||
return 0;
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.CmdPal.Core.ViewModels.Models;
|
using Microsoft.CmdPal.Core.ViewModels.Models;
|
||||||
using Microsoft.CommandPalette.Extensions;
|
using Microsoft.CommandPalette.Extensions;
|
||||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
|
||||||
|
|
||||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||||
|
|
||||||
@@ -109,8 +108,6 @@ public partial class ListItemViewModel(IListItem model, WeakReference<IPageConte
|
|||||||
|
|
||||||
// TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
|
// TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
|
||||||
// TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
|
// TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
|
||||||
public bool MatchesFilter(string filter) => StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success;
|
|
||||||
|
|
||||||
public override string ToString() => $"{Name} ListItemViewModel";
|
public override string ToString() => $"{Name} ListItemViewModel";
|
||||||
|
|
||||||
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
|
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<IPageConte
|
|||||||
{
|
{
|
||||||
// Tags being an ObservableCollection instead of a List lead to
|
// Tags being an ObservableCollection instead of a List lead to
|
||||||
// many COM exception issues.
|
// many COM exception issues.
|
||||||
Tags = new(newTags);
|
Tags = [.. newTags];
|
||||||
|
|
||||||
UpdateProperty(nameof(Tags));
|
UpdateProperty(nameof(Tags));
|
||||||
UpdateProperty(nameof(HasTags));
|
UpdateProperty(nameof(HasTags));
|
||||||
|
|||||||
@@ -173,12 +173,18 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check for cancellation before starting expensive operations
|
// Check for cancellation before starting expensive operations
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var newItems = _model.Unsafe!.GetItems();
|
var newItems = _model.Unsafe!.GetItems();
|
||||||
|
|
||||||
// Check for cancellation after getting items from extension
|
// Check for cancellation after getting items from extension
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO we can probably further optimize this by also keeping a
|
// TODO we can probably further optimize this by also keeping a
|
||||||
// HashSet of every ExtensionObject we currently have, and only
|
// HashSet of every ExtensionObject we currently have, and only
|
||||||
@@ -186,7 +192,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
foreach (var item in newItems)
|
foreach (var item in newItems)
|
||||||
{
|
{
|
||||||
// Check for cancellation during item processing
|
// Check for cancellation during item processing
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ListItemViewModel viewModel = new(item, new(this));
|
ListItemViewModel viewModel = new(item, new(this));
|
||||||
|
|
||||||
@@ -198,12 +207,19 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for cancellation before initializing first twenty items
|
// Check for cancellation before initializing first twenty items
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var firstTwenty = newViewModels.Take(20);
|
var firstTwenty = newViewModels.Take(20);
|
||||||
foreach (var item in firstTwenty)
|
foreach (var item in firstTwenty)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
item?.SafeInitializeProperties();
|
item?.SafeInitializeProperties();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +227,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
_cancellationTokenSource?.Cancel();
|
_cancellationTokenSource?.Cancel();
|
||||||
|
|
||||||
// Check for cancellation before updating the list
|
// Check for cancellation before updating the list
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
List<ListItemViewModel> removedItems = [];
|
List<ListItemViewModel> removedItems = [];
|
||||||
lock (_listLock)
|
lock (_listLock)
|
||||||
@@ -264,13 +283,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
_initializeItemsTask = new Task(() =>
|
_initializeItemsTask = new Task(() =>
|
||||||
{
|
{
|
||||||
try
|
InitializeItemsTask(_cancellationTokenSource.Token);
|
||||||
{
|
|
||||||
InitializeItemsTask(_cancellationTokenSource.Token);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
_initializeItemsTask.Start();
|
_initializeItemsTask.Start();
|
||||||
|
|
||||||
@@ -304,7 +317,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
private void InitializeItemsTask(CancellationToken ct)
|
private void InitializeItemsTask(CancellationToken ct)
|
||||||
{
|
{
|
||||||
// Were we already canceled?
|
// Were we already canceled?
|
||||||
ct.ThrowIfCancellationRequested();
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ListItemViewModel[] iterable;
|
ListItemViewModel[] iterable;
|
||||||
lock (_listLock)
|
lock (_listLock)
|
||||||
@@ -314,7 +330,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
|
|
||||||
foreach (var item in iterable)
|
foreach (var item in iterable)
|
||||||
{
|
{
|
||||||
ct.ThrowIfCancellationRequested();
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: GH #502
|
// TODO: GH #502
|
||||||
// We should probably remove the item from the list if it
|
// We should probably remove the item from the list if it
|
||||||
@@ -323,7 +342,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
// at once.
|
// at once.
|
||||||
item.SafeInitializeProperties();
|
item.SafeInitializeProperties();
|
||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
if (ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,9 +367,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title);
|
var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title);
|
||||||
var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle);
|
var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle);
|
||||||
return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max();
|
return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ScoredListItemViewModel
|
private struct ScoredListItemViewModel
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
|
using System.Diagnostics;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using ManagedCommon;
|
using ManagedCommon;
|
||||||
using Microsoft.CmdPal.Common.Helpers;
|
using Microsoft.CmdPal.Common.Helpers;
|
||||||
@@ -22,20 +23,27 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class MainListPage : DynamicListPage,
|
public partial class MainListPage : DynamicListPage,
|
||||||
IRecipient<ClearSearchMessage>,
|
IRecipient<ClearSearchMessage>,
|
||||||
IRecipient<UpdateFallbackItemsMessage>
|
IRecipient<UpdateFallbackItemsMessage>, 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 readonly TopLevelCommandManager _tlcManager;
|
||||||
private IEnumerable<Scored<IListItem>>? _filteredItems;
|
private IEnumerable<Scored<IListItem>>? _filteredItems;
|
||||||
private IEnumerable<Scored<IListItem>>? _filteredApps;
|
private IEnumerable<Scored<IListItem>>? _filteredApps;
|
||||||
private IEnumerable<IListItem>? _allApps;
|
private IEnumerable<Scored<IListItem>>? _fallbackItems;
|
||||||
private bool _includeApps;
|
private bool _includeApps;
|
||||||
private bool _filteredItemsIncludesApps;
|
private bool _filteredItemsIncludesApps;
|
||||||
|
private int _appResultLimit = 10;
|
||||||
|
|
||||||
private InterlockedBoolean _refreshRunning;
|
private InterlockedBoolean _refreshRunning;
|
||||||
private InterlockedBoolean _refreshRequested;
|
private InterlockedBoolean _refreshRequested;
|
||||||
|
|
||||||
|
private CancellationTokenSource? _cancellationTokenSource;
|
||||||
|
|
||||||
public MainListPage(IServiceProvider serviceProvider)
|
public MainListPage(IServiceProvider serviceProvider)
|
||||||
{
|
{
|
||||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
|
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.
|
// We just want to know when it is done.
|
||||||
var allApps = AllAppsCommandProvider.Page;
|
var allApps = AllAppsCommandProvider.Page;
|
||||||
allApps.PropChanged += (s, p) =>
|
allApps.PropChanged += (s, p) =>
|
||||||
{
|
|
||||||
if (p.PropertyName == nameof(allApps.IsLoading))
|
|
||||||
{
|
{
|
||||||
IsLoading = ActuallyLoading();
|
if (p.PropertyName == nameof(allApps.IsLoading))
|
||||||
}
|
{
|
||||||
};
|
IsLoading = ActuallyLoading();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
|
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
|
||||||
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
|
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
|
||||||
@@ -150,10 +158,23 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
{
|
{
|
||||||
lock (_tlcManager.TopLevelCommands)
|
lock (_tlcManager.TopLevelCommands)
|
||||||
{
|
{
|
||||||
|
IEnumerable<Scored<IListItem>> limitedApps = Enumerable.Empty<Scored<IListItem>>();
|
||||||
|
|
||||||
|
// 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<Scored<IListItem>>()
|
var items = Enumerable.Empty<Scored<IListItem>>()
|
||||||
.Concat(_filteredItems is not null ? _filteredItems : [])
|
.Concat(_filteredItems is not null ? _filteredItems : [])
|
||||||
.Concat(_filteredApps is not null ? _filteredApps : [])
|
.Concat(limitedApps)
|
||||||
.OrderByDescending(o => o.Score)
|
.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)
|
.Select(s => s.Item)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
return items;
|
return items;
|
||||||
@@ -163,10 +184,29 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
|
|
||||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
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
|
// Handle changes to the filter text here
|
||||||
if (!string.IsNullOrEmpty(SearchText))
|
if (!string.IsNullOrEmpty(SearchText))
|
||||||
{
|
{
|
||||||
var aliases = _serviceProvider.GetService<AliasManager>()!;
|
var aliases = _serviceProvider.GetService<AliasManager>()!;
|
||||||
|
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (aliases.CheckAlias(newSearch))
|
if (aliases.CheckAlias(newSearch))
|
||||||
{
|
{
|
||||||
if (_filteredItemsIncludesApps != _includeApps)
|
if (_filteredItemsIncludesApps != _includeApps)
|
||||||
@@ -176,7 +216,6 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
_filteredItemsIncludesApps = _includeApps;
|
_filteredItemsIncludesApps = _includeApps;
|
||||||
_filteredItems = null;
|
_filteredItems = null;
|
||||||
_filteredApps = null;
|
_filteredApps = null;
|
||||||
_allApps = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +223,20 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var commands = _tlcManager.TopLevelCommands;
|
var commands = _tlcManager.TopLevelCommands;
|
||||||
lock (commands)
|
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.
|
// Cleared out the filter text? easy. Reset _filteredItems, and bail out.
|
||||||
if (string.IsNullOrEmpty(newSearch))
|
if (string.IsNullOrEmpty(newSearch))
|
||||||
@@ -195,7 +244,7 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
_filteredItemsIncludesApps = _includeApps;
|
_filteredItemsIncludesApps = _includeApps;
|
||||||
_filteredItems = null;
|
_filteredItems = null;
|
||||||
_filteredApps = null;
|
_filteredApps = null;
|
||||||
_allApps = null;
|
_fallbackItems = null;
|
||||||
RaiseItemsChanged(commands.Count);
|
RaiseItemsChanged(commands.Count);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -206,7 +255,7 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
{
|
{
|
||||||
_filteredItems = null;
|
_filteredItems = null;
|
||||||
_filteredApps = null;
|
_filteredApps = null;
|
||||||
_allApps = null;
|
_fallbackItems = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the internal state has changed, reset _filteredItems to reset the list.
|
// If the internal state has changed, reset _filteredItems to reset the list.
|
||||||
@@ -214,61 +263,149 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
{
|
{
|
||||||
_filteredItems = null;
|
_filteredItems = null;
|
||||||
_filteredApps = null;
|
_filteredApps = null;
|
||||||
_allApps = null;
|
_fallbackItems = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var newFilteredItems = _filteredItems?.Select(s => s.Item);
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IEnumerable<IListItem> newFilteredItems = Enumerable.Empty<IListItem>();
|
||||||
|
IEnumerable<IListItem> newFallbacks = Enumerable.Empty<IListItem>();
|
||||||
|
IEnumerable<IListItem> newApps = Enumerable.Empty<IListItem>();
|
||||||
|
|
||||||
|
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
|
// If we don't have any previous filter results to work with, start
|
||||||
// with a list of all our commands & apps.
|
// 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<IListItem>();
|
||||||
|
|
||||||
|
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;
|
_filteredItemsIncludesApps = _includeApps;
|
||||||
|
|
||||||
if (_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.
|
// Produce a list of everything that matches the current filter.
|
||||||
_filteredItems = ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, ScoreTopLevelItem);
|
_filteredItems = ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, ScoreTopLevelItem);
|
||||||
|
|
||||||
// Produce a list of filtered apps with the appropriate limit
|
// Defaulting scored to 1 but we'll eventually use user rankings
|
||||||
if (_allApps is not null)
|
_fallbackItems = newFallbacks.Select(f => new Scored<IListItem> { Item = f, Score = 1 });
|
||||||
{
|
|
||||||
_filteredApps = ListHelpers.FilterListWithScores<IListItem>(_allApps, SearchText, ScoreTopLevelItem);
|
|
||||||
|
|
||||||
var appResultLimit = AllAppsCommandProvider.TopLevelResultLimit;
|
if (token.IsCancellationRequested)
|
||||||
if (appResultLimit >= 0)
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produce a list of filtered apps with the appropriate limit
|
||||||
|
if (newApps.Any())
|
||||||
|
{
|
||||||
|
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(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();
|
RaiseItemsChanged();
|
||||||
|
|
||||||
|
timer.Stop();
|
||||||
|
Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands)
|
private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands, CancellationToken token)
|
||||||
{
|
{
|
||||||
// fire and forget
|
_ = Task.Run(
|
||||||
_ = Task.Run(() =>
|
() =>
|
||||||
{
|
{
|
||||||
var needsToUpdate = false;
|
var needsToUpdate = false;
|
||||||
|
|
||||||
foreach (var command in commands)
|
foreach (var command in commands)
|
||||||
{
|
{
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch);
|
var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch);
|
||||||
needsToUpdate = needsToUpdate || changedVisibility;
|
needsToUpdate = needsToUpdate || changedVisibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsToUpdate)
|
if (needsToUpdate)
|
||||||
{
|
{
|
||||||
|
if (token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RaiseItemsChanged();
|
RaiseItemsChanged();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ActuallyLoading()
|
private bool ActuallyLoading()
|
||||||
@@ -322,19 +459,19 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
// * otherwise full weight match
|
// * otherwise full weight match
|
||||||
var nameMatch = isWhiteSpace ?
|
var nameMatch = isWhiteSpace ?
|
||||||
(title.Contains(query) ? 1 : 0) :
|
(title.Contains(query) ? 1 : 0) :
|
||||||
StringMatcher.FuzzySearch(query, title).Score;
|
FuzzyStringMatcher.ScoreFuzzy(query, title);
|
||||||
|
|
||||||
// Subtitle:
|
// Subtitle:
|
||||||
// * whitespace query: 1/2 point
|
// * whitespace query: 1/2 point
|
||||||
// * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer
|
// * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer
|
||||||
var descriptionMatch = isWhiteSpace ?
|
var descriptionMatch = isWhiteSpace ?
|
||||||
(topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) :
|
(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
|
// Extension title: despite not being visible, give the extension name itself some weight
|
||||||
// * whitespace query: 0 points
|
// * whitespace query: 0 points
|
||||||
// * otherwise more weight than a subtitle, but not much
|
// * 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[]
|
var scores = new[]
|
||||||
{
|
{
|
||||||
@@ -397,4 +534,22 @@ public partial class MainListPage : DynamicListPage,
|
|||||||
private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
|
private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
|
||||||
|
|
||||||
private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
|
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<SettingsModel>();
|
||||||
|
if (settings is not null)
|
||||||
|
{
|
||||||
|
settings.SettingsChanged -= SettingsChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ public static class TypedEventHandlerExtensions
|
|||||||
.OfType<TypedEventHandler<S, R>>()
|
.OfType<TypedEventHandler<S, R>>()
|
||||||
.Select(invocationDelegate =>
|
.Select(invocationDelegate =>
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
if (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return Task.FromCanceled(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
invocationDelegate(sender, eventArgs);
|
invocationDelegate(sender, eventArgs);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.CommandPalette.Extensions;
|
using Microsoft.CommandPalette.Extensions;
|
||||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
@@ -14,7 +10,9 @@ namespace Microsoft.CmdPal.Ext.UnitTestBase;
|
|||||||
|
|
||||||
public class CommandPaletteUnitTestBase
|
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)
|
public IListItem[] Query(string query, IListItem[] candidates)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,21 +51,21 @@ public partial class AllAppsCommandProvider : CommandProvider
|
|||||||
|
|
||||||
if (limitSetting is null)
|
if (limitSetting is null)
|
||||||
{
|
{
|
||||||
return -1;
|
return 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
var quantity = -1;
|
var quantity = 10;
|
||||||
|
|
||||||
if (int.TryParse(limitSetting, out var result))
|
if (int.TryParse(limitSetting, out var result))
|
||||||
{
|
{
|
||||||
quantity = result;
|
quantity = result < 0 ? quantity : result;
|
||||||
}
|
}
|
||||||
|
|
||||||
return quantity;
|
return quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public override ICommandItem[] TopLevelCommands() => [_listItem, .._page.GetPinnedApps()];
|
public override ICommandItem[] TopLevelCommands() => [_listItem, .. _page.GetPinnedApps()];
|
||||||
|
|
||||||
public ICommandItem? LookupApp(string displayName)
|
public ICommandItem? LookupApp(string displayName)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,16 +18,12 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
|
|||||||
|
|
||||||
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
|
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
|
||||||
|
|
||||||
private static string Experimental(string propertyName) => $"{_namespace}.experimental.{propertyName}";
|
|
||||||
|
|
||||||
private static readonly List<ChoiceSetSetting.Choice> _searchResultLimitChoices =
|
private static readonly List<ChoiceSetSetting.Choice> _searchResultLimitChoices =
|
||||||
[
|
[
|
||||||
new ChoiceSetSetting.Choice(Resources.limit_none, "-1"),
|
|
||||||
new ChoiceSetSetting.Choice(Resources.limit_0, "0"),
|
new ChoiceSetSetting.Choice(Resources.limit_0, "0"),
|
||||||
new ChoiceSetSetting.Choice(Resources.limit_1, "1"),
|
new ChoiceSetSetting.Choice(Resources.limit_1, "1"),
|
||||||
new ChoiceSetSetting.Choice(Resources.limit_5, "5"),
|
new ChoiceSetSetting.Choice(Resources.limit_5, "5"),
|
||||||
new ChoiceSetSetting.Choice(Resources.limit_10, "10"),
|
new ChoiceSetSetting.Choice(Resources.limit_10, "10"),
|
||||||
new ChoiceSetSetting.Choice(Resources.limit_20, "20"),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
#pragma warning disable SA1401 // Fields should be private
|
#pragma warning disable SA1401 // Fields should be private
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public partial class CalculatorCommandProvider : CommandProvider
|
|||||||
|
|
||||||
public CalculatorCommandProvider()
|
public CalculatorCommandProvider()
|
||||||
{
|
{
|
||||||
Id = "Calculator";
|
Id = "com.microsoft.cmdpal.builtin.calculator";
|
||||||
DisplayName = Resources.calculator_display_name;
|
DisplayName = Resources.calculator_display_name;
|
||||||
Icon = Icons.CalculatorIcon;
|
Icon = Icons.CalculatorIcon;
|
||||||
Settings = ((SettingsManager)settings).Settings;
|
Settings = ((SettingsManager)settings).Settings;
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ internal sealed partial class ShellListPage : DynamicListPage, IDisposable
|
|||||||
var filterHistory = (string query, KeyValuePair<string, ListItem> pair) =>
|
var filterHistory = (string query, KeyValuePair<string, ListItem> pair) =>
|
||||||
{
|
{
|
||||||
// Fuzzy search on the key (command string)
|
// Fuzzy search on the key (command string)
|
||||||
var score = StringMatcher.FuzzySearch(query, pair.Key).Score;
|
var score = FuzzyStringMatcher.ScoreFuzzy(query, pair.Key);
|
||||||
return score;
|
return score;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public partial class ShellCommandsProvider : CommandProvider
|
|||||||
{
|
{
|
||||||
_historyService = runHistoryService;
|
_historyService = runHistoryService;
|
||||||
|
|
||||||
Id = "Run";
|
Id = "com.microsoft.cmdpal.builtin.run";
|
||||||
DisplayName = Resources.cmd_plugin_name;
|
DisplayName = Resources.cmd_plugin_name;
|
||||||
Icon = Icons.RunV2Icon;
|
Icon = Icons.RunV2Icon;
|
||||||
Settings = _settingsManager.Settings;
|
Settings = _settingsManager.Settings;
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ internal sealed partial class FallbackSystemCommandItem : FallbackCommandItem
|
|||||||
{
|
{
|
||||||
var title = command.Title;
|
var title = command.Title;
|
||||||
var subTitle = command.Subtitle;
|
var subTitle = command.Subtitle;
|
||||||
var titleScore = StringMatcher.FuzzySearch(query, title).Score;
|
var titleScore = FuzzyStringMatcher.ScoreFuzzy(query, title);
|
||||||
var subTitleScore = StringMatcher.FuzzySearch(query, subTitle).Score;
|
var subTitleScore = FuzzyStringMatcher.ScoreFuzzy(query, subTitle);
|
||||||
|
|
||||||
var maxScore = Math.Max(titleScore, subTitleScore);
|
var maxScore = Math.Max(titleScore, subTitleScore);
|
||||||
if (maxScore > resultScore)
|
if (maxScore > resultScore)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// Copyright (c) Microsoft Corporation
|
// Copyright (c) Microsoft Corporation
|
||||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||||
// See the LICENSE file in the project root for more information.
|
// See the LICENSE file in the project root for more information.
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||||
|
|
||||||
namespace Microsoft.CmdPal.Ext.TimeDate.Helpers;
|
namespace Microsoft.CmdPal.Ext.TimeDate.Helpers;
|
||||||
@@ -64,12 +63,12 @@ internal sealed class AvailableResult
|
|||||||
public int Score(string query, string label, string tags)
|
public int Score(string query, string label, string tags)
|
||||||
{
|
{
|
||||||
// Get match for label (or for tags if label score is <1)
|
// 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)
|
if (score < 1)
|
||||||
{
|
{
|
||||||
foreach (var t in tags.Split(";"))
|
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)
|
if (tagScore > score)
|
||||||
{
|
{
|
||||||
score = tagScore / 2;
|
score = tagScore / 2;
|
||||||
|
|||||||
@@ -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<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
|
||||||
|
{
|
||||||
|
return (NOMATCH, new List<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = haystack.ToCharArray();
|
||||||
|
var query = needle.ToCharArray();
|
||||||
|
|
||||||
|
if (target.Length < query.Length)
|
||||||
|
{
|
||||||
|
return (NOMATCH, new List<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<int>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,17 +19,17 @@ public partial class ListHelpers
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title);
|
var nameMatchScore = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title);
|
||||||
|
|
||||||
// var locNameMatch = StringMatcher.FuzzySearch(query, NameLocalized);
|
// 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 executableNameMatch = StringMatcher.FuzzySearch(query, ExePath);
|
||||||
// var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized);
|
// var locExecutableNameMatch = StringMatcher.FuzzySearch(query, ExecutableNameLocalized);
|
||||||
// var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName);
|
// var lnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableName);
|
||||||
// var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized);
|
// var locLnkResolvedExecutableNameMatch = StringMatcher.FuzzySearch(query, LnkResolvedExecutableNameLocalized);
|
||||||
// var score = new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, executableNameMatch.Score }.Max();
|
// 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<IListItem> FilterList(IEnumerable<IListItem> items, string query)
|
public static IEnumerable<IListItem> FilterList(IEnumerable<IListItem> items, string query)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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
|
|
||||||
/// </summary>
|
|
||||||
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<int>();
|
|
||||||
List<int> spaceIndices = new List<int>();
|
|
||||||
|
|
||||||
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<int> 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<int> GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, int firstMatchIndexInWord, List<int> indexList)
|
|
||||||
{
|
|
||||||
var updatedList = new List<int>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user