// 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 CommunityToolkit.Mvvm.Messaging; using ManagedCommon; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Ext.Apps; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.Extensions.DependencyInjection; 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 partial class MainListPage : DynamicListPage, IRecipient, IRecipient { private readonly IServiceProvider _serviceProvider; private readonly TopLevelCommandManager _tlcManager; private IEnumerable? _filteredItems; private bool _includeApps; private bool _filteredItemsIncludesApps; public MainListPage(IServiceProvider serviceProvider) { Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png"); _serviceProvider = serviceProvider; _tlcManager = _serviceProvider.GetService()!; _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); var settings = _serviceProvider.GetService()!; settings.SettingsChanged += SettingsChangedHandler; HotReloadSettings(settings); 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(_tlcManager.TopLevelCommands.Count); } } private void ReapplySearchInBackground() { _ = Task.Run(() => { try { var currentSearchText = SearchText; UpdateSearchText(currentSearchText, currentSearchText); } catch (Exception e) { Logger.LogError("Failed to reload search", e); } }); } public override IListItem[] GetItems() { if (string.IsNullOrEmpty(SearchText)) { lock (_tlcManager.TopLevelCommands) { return _tlcManager .TopLevelCommands .Where(tlc => !string.IsNullOrEmpty(tlc.Title)) .ToArray(); } } else { lock (_tlcManager.TopLevelCommands) { return _filteredItems?.ToArray() ?? []; } } } public override void UpdateSearchText(string oldSearch, string newSearch) { // Handle changes to the filter text here if (!string.IsNullOrEmpty(SearchText)) { var aliases = _serviceProvider.GetService()!; if (aliases.CheckAlias(newSearch)) { return; } } var commands = _tlcManager.TopLevelCommands; lock (commands) { UpdateFallbacks(newSearch, commands.ToImmutableArray()); // Cleared out the filter text? easy. Reset _filteredItems, and bail out. if (string.IsNullOrEmpty(newSearch)) { _filteredItems = null; 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)) { _filteredItems = null; } // If the internal state has changed, reset _filteredItems to reset the list. if (_filteredItemsIncludesApps != _includeApps) { _filteredItems = null; } // If we don't have any previous filter results to work with, start // with a list of all our commands & apps. if (_filteredItems == null) { _filteredItems = commands; _filteredItemsIncludesApps = _includeApps; if (_includeApps) { IEnumerable apps = AllAppsCommandProvider.Page.GetItems(); _filteredItems = _filteredItems.Concat(apps); } } // Produce a list of everything that matches the current filter. _filteredItems = ListHelpers.FilterList(_filteredItems, SearchText, ScoreTopLevelItem); RaiseItemsChanged(_filteredItems.Count()); } } private void UpdateFallbacks(string newSearch, IReadOnlyList commands) { // fire and forget _ = Task.Run(() => { var needsToUpdate = false; foreach (var command in commands) { var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch); needsToUpdate = needsToUpdate || changedVisibility; } if (needsToUpdate) { RaiseItemsChanged(); } }); } private bool ActuallyLoading() { var tlcManager = _serviceProvider.GetService()!; 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. private int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem) { var title = topLevelOrAppItem.Title; if (string.IsNullOrWhiteSpace(title)) { return 0; } var isWhiteSpace = string.IsNullOrWhiteSpace(query); var isFallback = false; var isAliasSubstringMatch = false; var isAliasMatch = false; var id = IdForTopLevelOrAppItem(topLevelOrAppItem); var extensionDisplayName = string.Empty; if (topLevelOrAppItem is TopLevelViewModel topLevel) { isFallback = topLevel.IsFallback; if (topLevel.HasAlias) { var alias = topLevel.AliasText; isAliasMatch = alias == query; isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase); } extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty; } // StringMatcher.FuzzySearch will absolutely BEEF IT if you give it a // whitespace-only query. // // in that scenario, we'll just use a simple string contains for the // query. Maybe someone is really looking for things with a space in // them, I don't know. // Title: // * whitespace query: 1 point // * otherwise full weight match var nameMatch = isWhiteSpace ? (title.Contains(query) ? 1 : 0) : StringMatcher.FuzzySearch(query, title).Score; // 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; // 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 scores = new[] { nameMatch, descriptionMatch, isFallback ? 1 : 0, // Always give fallbacks a chance... }; var max = scores.Max(); // _Add_ the extension name. This will bubble items that match both // title and extension name up above ones that just match title. // e.g. "git" will up-weight "GitHub searches" from the GitHub extension // above "git" from "whatever" max = max + extensionTitleMatch; // ... but downweight them var matchSomething = (max / (isFallback ? 3 : 1)) + (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0)); // If we matched title, subtitle, or alias (something real), then // here we add the recent command weight boost // // Otherwise something like `x` will still match everything you've run before var finalScore = matchSomething; if (matchSomething > 0) { var history = _serviceProvider.GetService()!.RecentCommands; var recentWeightBoost = history.GetCommandHistoryWeight(id); finalScore += recentWeightBoost; } return (int)finalScore; } public void UpdateHistory(IListItem topLevelOrAppItem) { var id = IdForTopLevelOrAppItem(topLevelOrAppItem); var state = _serviceProvider.GetService()!; var history = state.RecentCommands; history.AddHistoryItem(id); AppStateModel.SaveState(state); } private 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; }