MainListPage.cs
  1  // Copyright (c) Microsoft Corporation
  2  // The Microsoft Corporation licenses this file to you under the MIT license.
  3  // See the LICENSE file in the project root for more information.
  4  
  5  using System.Collections.Immutable;
  6  using System.Collections.Specialized;
  7  using System.Diagnostics;
  8  using CommunityToolkit.Mvvm.Messaging;
  9  using ManagedCommon;
 10  using Microsoft.CmdPal.Core.Common.Helpers;
 11  using Microsoft.CmdPal.Core.ViewModels.Messages;
 12  using Microsoft.CmdPal.Ext.Apps;
 13  using Microsoft.CmdPal.Ext.Apps.Programs;
 14  using Microsoft.CmdPal.Ext.Apps.State;
 15  using Microsoft.CmdPal.UI.ViewModels.Commands;
 16  using Microsoft.CmdPal.UI.ViewModels.Messages;
 17  using Microsoft.CmdPal.UI.ViewModels.Properties;
 18  using Microsoft.CommandPalette.Extensions;
 19  using Microsoft.CommandPalette.Extensions.Toolkit;
 20  
 21  namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
 22  
 23  /// <summary>
 24  /// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a <see cref="ListPage"/>.
 25  /// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels.
 26  /// </summary>
 27  public partial class MainListPage : DynamicListPage,
 28      IRecipient<ClearSearchMessage>,
 29      IRecipient<UpdateFallbackItemsMessage>, IDisposable
 30  {
 31      private readonly TopLevelCommandManager _tlcManager;
 32      private readonly AliasManager _aliasManager;
 33      private readonly SettingsModel _settings;
 34      private readonly AppStateModel _appStateModel;
 35      private List<Scored<IListItem>>? _filteredItems;
 36      private List<Scored<IListItem>>? _filteredApps;
 37  
 38      // Keep as IEnumerable for deferred execution. Fallback item titles are updated
 39      // asynchronously, so scoring must happen lazily when GetItems is called.
 40      private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
 41      private IEnumerable<Scored<IListItem>>? _fallbackItems;
 42      private bool _includeApps;
 43      private bool _filteredItemsIncludesApps;
 44      private int _appResultLimit = 10;
 45  
 46      private InterlockedBoolean _refreshRunning;
 47      private InterlockedBoolean _refreshRequested;
 48  
 49      private CancellationTokenSource? _cancellationTokenSource;
 50  
 51      public MainListPage(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
 52      {
 53          Title = Resources.builtin_home_name;
 54          Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
 55          PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;
 56  
 57          _settings = settings;
 58          _aliasManager = aliasManager;
 59          _appStateModel = appStateModel;
 60          _tlcManager = topLevelCommandManager;
 61          _tlcManager.PropertyChanged += TlcManager_PropertyChanged;
 62          _tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
 63  
 64          // The all apps page will kick off a BG thread to start loading apps.
 65          // We just want to know when it is done.
 66          var allApps = AllAppsCommandProvider.Page;
 67          allApps.PropChanged += (s, p) =>
 68              {
 69                  if (p.PropertyName == nameof(allApps.IsLoading))
 70                  {
 71                      IsLoading = ActuallyLoading();
 72                  }
 73              };
 74  
 75          WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
 76          WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
 77  
 78          settings.SettingsChanged += SettingsChangedHandler;
 79          HotReloadSettings(settings);
 80          _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
 81  
 82          IsLoading = true;
 83      }
 84  
 85      private void TlcManager_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
 86      {
 87          if (e.PropertyName == nameof(IsLoading))
 88          {
 89              IsLoading = ActuallyLoading();
 90          }
 91      }
 92  
 93      private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
 94      {
 95          _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
 96          if (_includeApps != _filteredItemsIncludesApps)
 97          {
 98              ReapplySearchInBackground();
 99          }
100          else
101          {
102              RaiseItemsChanged();
103          }
104      }
105  
106      private void ReapplySearchInBackground()
107      {
108          _refreshRequested.Set();
109          if (!_refreshRunning.Set())
110          {
111              return;
112          }
113  
114          _ = Task.Run(RunRefreshLoop);
115      }
116  
117      private void RunRefreshLoop()
118      {
119          try
120          {
121              do
122              {
123                  _refreshRequested.Clear();
124                  lock (_tlcManager.TopLevelCommands)
125                  {
126                      if (_filteredItemsIncludesApps == _includeApps)
127                      {
128                          break;
129                      }
130                  }
131  
132                  var currentSearchText = SearchText;
133                  UpdateSearchText(currentSearchText, currentSearchText);
134              }
135              while (_refreshRequested.Value);
136          }
137          catch (Exception e)
138          {
139              Logger.LogError("Failed to reload search", e);
140          }
141          finally
142          {
143              _refreshRunning.Clear();
144              if (_refreshRequested.Value && _refreshRunning.Set())
145              {
146                  _ = Task.Run(RunRefreshLoop);
147              }
148          }
149      }
150  
151      public override IListItem[] GetItems()
152      {
153          lock (_tlcManager.TopLevelCommands)
154          {
155              // Either return the top-level commands (no search text), or the merged and
156              // filtered results.
157              if (string.IsNullOrWhiteSpace(SearchText))
158              {
159                  return _tlcManager.TopLevelCommands
160                      .Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
161                      .ToArray();
162              }
163              else
164              {
165                  var validScoredFallbacks = _scoredFallbackItems?
166                      .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
167                      .ToList();
168  
169                  var validFallbacks = _fallbackItems?
170                      .Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
171                      .ToList();
172  
173                  return MainListPageResultFactory.Create(
174                      _filteredItems,
175                      validScoredFallbacks,
176                      _filteredApps,
177                      validFallbacks,
178                      _appResultLimit);
179              }
180          }
181      }
182  
183      private void ClearResults()
184      {
185          _filteredItems = null;
186          _filteredApps = null;
187          _fallbackItems = null;
188          _scoredFallbackItems = null;
189      }
190  
191      public override void UpdateSearchText(string oldSearch, string newSearch)
192      {
193          var timer = new Stopwatch();
194          timer.Start();
195  
196          _cancellationTokenSource?.Cancel();
197          _cancellationTokenSource?.Dispose();
198          _cancellationTokenSource = new CancellationTokenSource();
199  
200          var token = _cancellationTokenSource.Token;
201          if (token.IsCancellationRequested)
202          {
203              return;
204          }
205  
206          // Handle changes to the filter text here
207          if (!string.IsNullOrEmpty(SearchText))
208          {
209              var aliases = _aliasManager;
210  
211              if (token.IsCancellationRequested)
212              {
213                  return;
214              }
215  
216              if (aliases.CheckAlias(newSearch))
217              {
218                  if (_filteredItemsIncludesApps != _includeApps)
219                  {
220                      lock (_tlcManager.TopLevelCommands)
221                      {
222                          _filteredItemsIncludesApps = _includeApps;
223                          ClearResults();
224                      }
225                  }
226  
227                  return;
228              }
229          }
230  
231          if (token.IsCancellationRequested)
232          {
233              return;
234          }
235  
236          var commands = _tlcManager.TopLevelCommands;
237          lock (commands)
238          {
239              if (token.IsCancellationRequested)
240              {
241                  return;
242              }
243  
244              // prefilter fallbacks
245              var globalFallbacks = _settings.GetGlobalFallbacks();
246              var specialFallbacks = new List<TopLevelViewModel>(globalFallbacks.Length);
247              var commonFallbacks = new List<TopLevelViewModel>();
248  
249              foreach (var s in commands)
250              {
251                  if (!s.IsFallback)
252                  {
253                      continue;
254                  }
255  
256                  if (globalFallbacks.Contains(s.Id))
257                  {
258                      specialFallbacks.Add(s);
259                  }
260                  else
261                  {
262                      commonFallbacks.Add(s);
263                  }
264              }
265  
266              // start update of fallbacks; update special fallbacks separately,
267              // so they can finish faster
268              UpdateFallbacks(SearchText, specialFallbacks, token);
269              UpdateFallbacks(SearchText, commonFallbacks, token);
270  
271              if (token.IsCancellationRequested)
272              {
273                  return;
274              }
275  
276              // Cleared out the filter text? easy. Reset _filteredItems, and bail out.
277              if (string.IsNullOrEmpty(newSearch))
278              {
279                  _filteredItemsIncludesApps = _includeApps;
280                  ClearResults();
281                  RaiseItemsChanged(commands.Count);
282                  return;
283              }
284  
285              // If the new string doesn't start with the old string, then we can't
286              // re-use previous results. Reset _filteredItems, and keep er moving.
287              if (!newSearch.StartsWith(oldSearch, StringComparison.CurrentCultureIgnoreCase))
288              {
289                  ClearResults();
290              }
291  
292              // If the internal state has changed, reset _filteredItems to reset the list.
293              if (_filteredItemsIncludesApps != _includeApps)
294              {
295                  ClearResults();
296              }
297  
298              if (token.IsCancellationRequested)
299              {
300                  return;
301              }
302  
303              var newFilteredItems = Enumerable.Empty<IListItem>();
304              var newFallbacks = Enumerable.Empty<IListItem>();
305              var newApps = Enumerable.Empty<IListItem>();
306  
307              if (_filteredItems is not null)
308              {
309                  newFilteredItems = _filteredItems.Select(s => s.Item);
310              }
311  
312              if (token.IsCancellationRequested)
313              {
314                  return;
315              }
316  
317              if (_filteredApps is not null)
318              {
319                  newApps = _filteredApps.Select(s => s.Item);
320              }
321  
322              if (token.IsCancellationRequested)
323              {
324                  return;
325              }
326  
327              if (_fallbackItems is not null)
328              {
329                  newFallbacks = _fallbackItems.Select(s => s.Item);
330              }
331  
332              if (token.IsCancellationRequested)
333              {
334                  return;
335              }
336  
337              // If we don't have any previous filter results to work with, start
338              // with a list of all our commands & apps.
339              if (!newFilteredItems.Any() && !newApps.Any())
340              {
341                  newFilteredItems = commands.Where(s => !s.IsFallback);
342  
343                  // Fallbacks are always included in the list, even if they
344                  // don't match the search text. But we don't want to
345                  // consider them when filtering the list.
346                  newFallbacks = commonFallbacks;
347  
348                  if (token.IsCancellationRequested)
349                  {
350                      return;
351                  }
352  
353                  _filteredItemsIncludesApps = _includeApps;
354  
355                  if (_includeApps)
356                  {
357                      var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList();
358  
359                      // We need to remove pinned apps from allNewApps so they don't show twice.
360                      var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
361  
362                      if (pinnedApps.Length > 0)
363                      {
364                          newApps = allNewApps.Where(w =>
365                              pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0);
366                      }
367                      else
368                      {
369                          newApps = allNewApps;
370                      }
371                  }
372  
373                  if (token.IsCancellationRequested)
374                  {
375                      return;
376                  }
377              }
378  
379              var history = _appStateModel.RecentCommands!;
380              Func<string, IListItem, int> scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
381  
382              // Produce a list of everything that matches the current filter.
383              _filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, scoreItem)];
384  
385              if (token.IsCancellationRequested)
386              {
387                  return;
388              }
389  
390              IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
391  
392              if (token.IsCancellationRequested)
393              {
394                  return;
395              }
396  
397              _scoredFallbackItems = ListHelpers.FilterListWithScores<IListItem>(newFallbacksForScoring ?? [], SearchText, scoreItem);
398  
399              if (token.IsCancellationRequested)
400              {
401                  return;
402              }
403  
404              Func<string, IListItem, int> scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); };
405              _fallbackItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], SearchText, scoreFallbackItem)];
406  
407              if (token.IsCancellationRequested)
408              {
409                  return;
410              }
411  
412              // Produce a list of filtered apps with the appropriate limit
413              if (newApps.Any())
414              {
415                  var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, scoreItem);
416  
417                  if (token.IsCancellationRequested)
418                  {
419                      return;
420                  }
421  
422                  // We'll apply this limit in the GetItems method after merging with commands
423                  // but we need to know the limit now to avoid re-scoring apps
424                  var appLimit = AllAppsCommandProvider.TopLevelResultLimit;
425  
426                  _filteredApps = [.. scoredApps];
427  
428                  if (token.IsCancellationRequested)
429                  {
430                      return;
431                  }
432              }
433  
434              RaiseItemsChanged();
435  
436              timer.Stop();
437              Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms");
438          }
439      }
440  
441      private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands, CancellationToken token)
442      {
443          _ = Task.Run(
444              () =>
445          {
446              var needsToUpdate = false;
447  
448              foreach (var command in commands)
449              {
450                  if (token.IsCancellationRequested)
451                  {
452                      return;
453                  }
454  
455                  var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch);
456                  needsToUpdate = needsToUpdate || changedVisibility;
457              }
458  
459              if (needsToUpdate)
460              {
461                  if (token.IsCancellationRequested)
462                  {
463                      return;
464                  }
465  
466                  RaiseItemsChanged();
467              }
468          },
469              token);
470      }
471  
472      private bool ActuallyLoading()
473      {
474          var allApps = AllAppsCommandProvider.Page;
475          return allApps.IsLoading || _tlcManager.IsLoading;
476      }
477  
478      // Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
479      // fact that we want fallback handlers down-weighted, so that they don't
480      // _always_ show up first.
481      internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history)
482      {
483          var title = topLevelOrAppItem.Title;
484          if (string.IsNullOrWhiteSpace(title))
485          {
486              return 0;
487          }
488  
489          var isWhiteSpace = string.IsNullOrWhiteSpace(query);
490  
491          var isFallback = false;
492          var isAliasSubstringMatch = false;
493          var isAliasMatch = false;
494          var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
495  
496          var extensionDisplayName = string.Empty;
497          if (topLevelOrAppItem is TopLevelViewModel topLevel)
498          {
499              isFallback = topLevel.IsFallback;
500              if (topLevel.HasAlias)
501              {
502                  var alias = topLevel.AliasText;
503                  isAliasMatch = alias == query;
504                  isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase);
505              }
506  
507              extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty;
508          }
509  
510          // StringMatcher.FuzzySearch will absolutely BEEF IT if you give it a
511          // whitespace-only query.
512          //
513          // in that scenario, we'll just use a simple string contains for the
514          // query. Maybe someone is really looking for things with a space in
515          // them, I don't know.
516  
517          // Title:
518          // * whitespace query: 1 point
519          // * otherwise full weight match
520          var nameMatch = isWhiteSpace ?
521              (title.Contains(query) ? 1 : 0) :
522                 FuzzyStringMatcher.ScoreFuzzy(query, title);
523  
524          // Subtitle:
525          // * whitespace query: 1/2 point
526          // * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer
527          var descriptionMatch = isWhiteSpace ?
528              (topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) :
529              (FuzzyStringMatcher.ScoreFuzzy(query, topLevelOrAppItem.Subtitle) - 4) / 2.0;
530  
531          // Extension title: despite not being visible, give the extension name itself some weight
532          // * whitespace query: 0 points
533          // * otherwise more weight than a subtitle, but not much
534          var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5;
535  
536          var scores = new[]
537          {
538               nameMatch,
539               descriptionMatch,
540               isFallback ? 1 : 0, // Always give fallbacks a chance
541          };
542          var max = scores.Max();
543  
544          // _Add_ the extension name. This will bubble items that match both
545          // title and extension name up above ones that just match title.
546          // e.g. "git" will up-weight "GitHub searches" from the GitHub extension
547          // above "git" from "whatever"
548          max = max + extensionTitleMatch;
549  
550          // Apply a penalty to fallback items so they rank below direct matches.
551          // Fallbacks that dynamically match queries (like RDP connections) should
552          // appear after apps and direct command matches.
553          if (isFallback && max > 1)
554          {
555              // Reduce fallback scores by 50% to prioritize direct matches
556              max = max * 0.5;
557          }
558  
559          var matchSomething = max
560              + (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));
561  
562          // If we matched title, subtitle, or alias (something real), then
563          // here we add the recent command weight boost
564          //
565          // Otherwise something like `x` will still match everything you've run before
566          var finalScore = matchSomething * 10;
567          if (matchSomething > 0)
568          {
569              var recentWeightBoost = history.GetCommandHistoryWeight(id);
570              finalScore += recentWeightBoost;
571          }
572  
573          return (int)finalScore;
574      }
575  
576      internal static int ScoreFallbackItem(string query, IListItem topLevelOrAppItem, string[] fallbackRanks)
577      {
578          // Default to 1 so it always shows in list.
579          var finalScore = 1;
580  
581          if (topLevelOrAppItem is TopLevelViewModel topLevelViewModel)
582          {
583              var index = Array.IndexOf(fallbackRanks, topLevelViewModel.Id);
584  
585              if (index >= 0)
586              {
587                  finalScore = fallbackRanks.Length - index + 1;
588              }
589          }
590  
591          return finalScore;
592      }
593  
594      public void UpdateHistory(IListItem topLevelOrAppItem)
595      {
596          var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
597          var history = _appStateModel.RecentCommands;
598          history.AddHistoryItem(id);
599          AppStateModel.SaveState(_appStateModel);
600      }
601  
602      private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem)
603      {
604          if (topLevelOrAppItem is TopLevelViewModel topLevel)
605          {
606              return topLevel.Id;
607          }
608          else
609          {
610              // we've got an app here
611              return topLevelOrAppItem.Command?.Id ?? string.Empty;
612          }
613      }
614  
615      public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
616  
617      public void Receive(UpdateFallbackItemsMessage message) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count);
618  
619      private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
620  
621      private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails;
622  
623      public void Dispose()
624      {
625          _cancellationTokenSource?.Cancel();
626          _cancellationTokenSource?.Dispose();
627  
628          _tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
629          _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
630  
631          if (_settings is not null)
632          {
633              _settings.SettingsChanged -= SettingsChangedHandler;
634          }
635  
636          WeakReferenceMessenger.Default.UnregisterAll(this);
637          GC.SuppressFinalize(this);
638      }
639  }