// 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.Collections.Immutable;
using System.Collections.Specialized;
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
///
/// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a .
/// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels.
///
public sealed partial class MainListPage : DynamicListPage,
IRecipient,
IRecipient, IDisposable
{
private readonly TopLevelCommandManager _tlcManager;
private readonly AliasManager _aliasManager;
private readonly SettingsModel _settings;
private readonly AppStateModel _appStateModel;
private readonly ScoringFunction _scoringFunction;
private readonly ScoringFunction _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private RoScored[]? _filteredItems;
private RoScored[]? _filteredApps;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable>? _scoredFallbackItems;
private IEnumerable>? _fallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
private InterlockedBoolean _refreshRunning;
private InterlockedBoolean _refreshRequested;
private CancellationTokenSource? _cancellationTokenSource;
public MainListPage(
TopLevelCommandManager topLevelCommandManager,
SettingsModel settings,
AliasManager aliasManager,
AppStateModel appStateModel,
IFuzzyMatcherProvider fuzzyMatcherProvider)
{
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;
_settings = settings;
_aliasManager = aliasManager;
_appStateModel = appStateModel;
_tlcManager = topLevelCommandManager;
_fuzzyMatcherProvider = fuzzyMatcherProvider;
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current);
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks);
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
// The all apps page will kick off a BG thread to start loading apps.
// 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();
}
};
WeakReferenceMessenger.Default.Register(this);
WeakReferenceMessenger.Default.Register(this);
settings.SettingsChanged += SettingsChangedHandler;
HotReloadSettings(settings);
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
IsLoading = true;
}
private void TlcManager_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(IsLoading))
{
IsLoading = ActuallyLoading();
}
}
private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
if (_includeApps != _filteredItemsIncludesApps)
{
ReapplySearchInBackground();
}
else
{
RaiseItemsChanged();
}
}
private void ReapplySearchInBackground()
{
_refreshRequested.Set();
if (!_refreshRunning.Set())
{
return;
}
_ = Task.Run(RunRefreshLoop);
}
private void RunRefreshLoop()
{
try
{
do
{
_refreshRequested.Clear();
lock (_tlcManager.TopLevelCommands)
{
if (_filteredItemsIncludesApps == _includeApps)
{
break;
}
}
var currentSearchText = SearchText;
UpdateSearchText(currentSearchText, currentSearchText);
}
while (_refreshRequested.Value);
}
catch (Exception e)
{
Logger.LogError("Failed to reload search", e);
}
finally
{
_refreshRunning.Clear();
if (_refreshRequested.Value && _refreshRunning.Set())
{
_ = Task.Run(RunRefreshLoop);
}
}
}
public override IListItem[] GetItems()
{
lock (_tlcManager.TopLevelCommands)
{
// Either return the top-level commands (no search text), or the merged and
// filtered results.
if (string.IsNullOrWhiteSpace(SearchText))
{
var allCommands = _tlcManager.TopLevelCommands;
// First pass: count eligible commands
var eligibleCount = 0;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
eligibleCount++;
}
}
if (eligibleCount == 0)
{
return [];
}
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _resultsSeparator;
// Second pass: populate
var writeIndex = 1;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
result[writeIndex++] = cmd;
}
}
return result;
}
else
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
}
}
private void ClearResults()
{
_filteredItems = null;
_filteredApps = null;
_fallbackItems = null;
_scoredFallbackItems = null;
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
var stopwatch = Stopwatch.StartNew();
_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 = _aliasManager;
if (token.IsCancellationRequested)
{
return;
}
if (aliases.CheckAlias(newSearch))
{
if (_filteredItemsIncludesApps != _includeApps)
{
lock (_tlcManager.TopLevelCommands)
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
}
}
return;
}
}
if (token.IsCancellationRequested)
{
return;
}
var commands = _tlcManager.TopLevelCommands;
lock (commands)
{
if (token.IsCancellationRequested)
{
return;
}
// prefilter fallbacks
var globalFallbacks = _settings.GetGlobalFallbacks();
var specialFallbacks = new List(globalFallbacks.Length);
var commonFallbacks = new List();
foreach (var s in commands)
{
if (!s.IsFallback)
{
continue;
}
if (globalFallbacks.Contains(s.Id))
{
specialFallbacks.Add(s);
}
else
{
commonFallbacks.Add(s);
}
}
// start update of fallbacks; update special fallbacks separately,
// so they can finish faster
UpdateFallbacks(SearchText, specialFallbacks, token);
UpdateFallbacks(SearchText, commonFallbacks, token);
if (token.IsCancellationRequested)
{
return;
}
// Cleared out the filter text? easy. Reset _filteredItems, and bail out.
if (string.IsNullOrEmpty(newSearch))
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
RaiseItemsChanged(commands.Count);
return;
}
// If the new string doesn't start with the old string, then we can't
// re-use previous results. Reset _filteredItems, and keep er moving.
if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
{
ClearResults();
}
// If the internal state has changed, reset _filteredItems to reset the list.
if (_filteredItemsIncludesApps != _includeApps)
{
ClearResults();
}
if (token.IsCancellationRequested)
{
return;
}
var newFilteredItems = Enumerable.Empty();
var newFallbacks = Enumerable.Empty();
var 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.Any() && !newApps.Any())
{
newFilteredItems = commands.Where(s => !s.IsFallback);
// 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 = commonFallbacks;
if (token.IsCancellationRequested)
{
return;
}
_filteredItemsIncludesApps = _includeApps;
if (_includeApps)
{
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
// Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds.
_settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings);
var pinnedCommandIds = providerSettings?.PinnedCommandIds;
if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0)
{
newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id));
}
else
{
newApps = allNewApps;
}
}
if (token.IsCancellationRequested)
{
return;
}
}
var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
// Produce a list of everything that matches the current filter.
_filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
IEnumerable newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
_scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
_fallbackItems = InternalListHelpers.FilterListWithScores(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
if (token.IsCancellationRequested)
{
return;
}
// Produce a list of filtered apps with the appropriate limit
if (newApps.Any())
{
_filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
}
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms");
RaiseItemsChanged();
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
stopwatch.Stop();
}
}
private void UpdateFallbacks(string newSearch, IReadOnlyList commands, CancellationToken token)
{
_ = 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()
{
var allApps = AllAppsCommandProvider.Page;
return allApps.IsLoading || _tlcManager.IsLoading;
}
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
// fact that we want fallback handlers down-weighted, so that they don't
// _always_ show up first.
internal static int ScoreTopLevelItem(
in FuzzyQuery query,
IListItem topLevelOrAppItem,
IRecentCommandsManager history,
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
{
var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title))
{
return 0;
}
var isFallback = false;
var isAliasSubstringMatch = false;
var isAliasMatch = false;
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
FuzzyTarget? extensionDisplayNameTarget = null;
if (topLevelOrAppItem is TopLevelViewModel topLevel)
{
isFallback = topLevel.IsFallback;
extensionDisplayNameTarget = topLevel.GetExtensionNameTarget(precomputedFuzzyMatcher);
if (topLevel.HasAlias)
{
var alias = topLevel.AliasText;
isAliasMatch = alias == query.Original;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
}
}
// Handle whitespace query separately - FuzzySearch doesn't handle it well
if (string.IsNullOrWhiteSpace(query.Original))
{
return ScoreWhitespaceQuery(query.Original, title, topLevelOrAppItem.Subtitle, isFallback);
}
// Get precomputed targets
var (titleTarget, subtitleTarget) = topLevelOrAppItem is IPrecomputedListItem precomputedItem
? (precomputedItem.GetTitleTarget(precomputedFuzzyMatcher), precomputedItem.GetSubtitleTarget(precomputedFuzzyMatcher))
: (precomputedFuzzyMatcher.PrecomputeTarget(title), precomputedFuzzyMatcher.PrecomputeTarget(topLevelOrAppItem.Subtitle));
// Score components
var nameScore = precomputedFuzzyMatcher.Score(query, titleTarget);
var descriptionScore = (precomputedFuzzyMatcher.Score(query, subtitleTarget) - 4) / 2.0;
var extensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) / 1.5 : 0;
// Take best match from title/description/fallback, then add extension score
// Extension adds to max so items matching both title AND extension bubble up
var baseScore = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0);
var matchScore = baseScore + extensionScore;
// Apply a penalty to fallback items so they rank below direct matches.
// Fallbacks that dynamically match queries (like RDP connections) should
// appear after apps and direct command matches.
if (isFallback && matchScore > 1)
{
// Reduce fallback scores by 50% to prioritize direct matches
matchScore = matchScore * 0.5;
}
// Alias matching: exact match is overwhelming priority, substring match adds a small boost
var aliasBoost = isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0);
var totalMatch = matchScore + aliasBoost;
// Apply scaling and history boost only if we matched something real
var finalScore = totalMatch * 10;
if (totalMatch > 0)
{
finalScore += history.GetCommandHistoryWeight(id);
}
return (int)finalScore;
}
private static int ScoreWhitespaceQuery(string query, string title, string subtitle, bool isFallback)
{
// Simple contains check for whitespace queries
var nameMatch = title.Contains(query, StringComparison.Ordinal) ? 1.0 : 0;
var descriptionMatch = subtitle.Contains(query, StringComparison.Ordinal) ? 0.5 : 0;
var baseScore = Math.Max(Math.Max(nameMatch, descriptionMatch), isFallback ? 1 : 0);
return (int)(baseScore * 10);
}
private static int ScoreFallbackItem(IListItem topLevelOrAppItem, string[] fallbackRanks)
{
// Default to 1 so it always shows in list.
var finalScore = 1;
if (topLevelOrAppItem is TopLevelViewModel topLevelViewModel)
{
var index = Array.IndexOf(fallbackRanks, topLevelViewModel.Id);
if (index >= 0)
{
finalScore = fallbackRanks.Length - index + 1;
}
}
return finalScore;
}
public void UpdateHistory(IListItem topLevelOrAppItem)
{
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var history = _appStateModel.RecentCommands;
history.AddHistoryItem(id);
AppStateModel.SaveState(_appStateModel);
}
private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
{
if (topLevelOrAppItem is TopLevelViewModel topLevel)
{
return topLevel.Id;
}
else
{
// we've got an app here
return topLevelOrAppItem.Command?.Id ?? string.Empty;
}
}
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
public void Receive(UpdateFallbackItemsMessage message) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count);
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;
if (_settings is not null)
{
_settings.SettingsChanged -= SettingsChangedHandler;
}
WeakReferenceMessenger.Default.UnregisterAll(this);
GC.SuppressFinalize(this);
}
}