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 }