// 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.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; namespace Microsoft.CmdPal.UI.ViewModels; public partial class ListViewModel : PageViewModel, IDisposable { // private readonly HashSet _itemCache = []; // TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items? // Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change // https://learn.microsoft.com/dotnet/communitytoolkit/mvvm/observablegroupedcollections for grouping support [ObservableProperty] public partial ObservableCollection FilteredItems { get; set; } = []; private ObservableCollection Items { get; set; } = []; private readonly ExtensionObject _model; private readonly Lock _listLock = new(); private bool _isLoading; private bool _isFetching; public event TypedEventHandler? ItemsUpdated; public bool ShowEmptyContent => IsInitialized && FilteredItems.Count == 0 && (!_isFetching) && IsLoading == false; // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] public bool ShowDetails { get; private set; } private string _modelPlaceholderText = string.Empty; public override string PlaceholderText => _modelPlaceholderText; public string SearchText { get; private set; } = string.Empty; public string InitialSearchText { get; private set; } = string.Empty; public CommandItemViewModel EmptyContent { get; private set; } private bool _isDynamic; private Task? _initializeItemsTask; private CancellationTokenSource? _cancellationTokenSource; private ListItemViewModel? _lastSelectedItem; public override bool IsInitialized { get => base.IsInitialized; protected set { base.IsInitialized = value; UpdateEmptyContent(); } } public ListViewModel(IListPage model, TaskScheduler scheduler, CommandPaletteHost host) : base(model, scheduler, host) { _model = new(model); EmptyContent = new(new(null), PageContext); } // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); protected override void OnFilterUpdated(string filter) { //// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view //// and manage filtering below, but we should be smarter about this and understand caching and other requirements... //// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList? // Dynamic pages will handler their own filtering. They will tell us if // something needs to change, by raising ItemsChanged. if (_isDynamic) { // We're getting called on the UI thread. // Hop off to a BG thread to update the extension. _ = Task.Run(() => { try { if (_model.Unsafe is IDynamicListPage dynamic) { dynamic.SearchText = filter; } } catch (Exception ex) { ShowException(ex, _model?.Unsafe?.Name); } }); } else { // But for all normal pages, we should run our fuzzy match on them. lock (_listLock) { ApplyFilterUnderLock(); } ItemsUpdated?.Invoke(this, EventArgs.Empty); UpdateEmptyContent(); _isLoading = false; } } //// Run on background thread, from InitializeAsync or Model_ItemsChanged private void FetchItems() { // TEMPORARY: just plop all the items into a single group // see 9806fe5d8 for the last commit that had this with sections _isFetching = true; try { var newItems = _model.Unsafe!.GetItems(); // Collect all the items into new viewmodels Collection newViewModels = []; // TODO we can probably further optimize this by also keeping a // HashSet of every ExtensionObject we currently have, and only // building new viewmodels for the ones we haven't already built. foreach (var item in newItems) { ListItemViewModel viewModel = new(item, new(this)); // If an item fails to load, silently ignore it. if (viewModel.SafeFastInit()) { newViewModels.Add(viewModel); } } var firstTwenty = newViewModels.Take(20); foreach (var item in firstTwenty) { item?.SafeInitializeProperties(); } // Cancel any ongoing search if (_cancellationTokenSource != null) { _cancellationTokenSource.Cancel(); } lock (_listLock) { // Now that we have new ViewModels for everything from the // extension, smartly update our list of VMs ListHelpers.InPlaceUpdateList(Items, newViewModels); } // TODO: Iterate over everything in Items, and prune items from the // cache if we don't need them anymore } catch (Exception ex) { // TODO: Move this within the for loop, so we can catch issues with individual items // Create a special ListItemViewModel for errors and use an ItemTemplateSelector in the ListPage to display error items differently. ShowException(ex, _model?.Unsafe?.Name); throw; } finally { _isFetching = false; } _cancellationTokenSource = new CancellationTokenSource(); _initializeItemsTask = new Task(() => { try { InitializeItemsTask(_cancellationTokenSource.Token); } catch (OperationCanceledException) { } }); _initializeItemsTask.Start(); DoOnUiThread( () => { lock (_listLock) { // Now that our Items contains everything we want, it's time for us to // re-evaluate our Filter on those items. if (!_isDynamic) { // A static list? Great! Just run the filter. ApplyFilterUnderLock(); } else { // A dynamic list? Even better! Just stick everything into // FilteredItems. The extension already did any filtering it cared about. ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState)); } UpdateEmptyContent(); } ItemsUpdated?.Invoke(this, EventArgs.Empty); _isLoading = false; }); } private void InitializeItemsTask(CancellationToken ct) { // Were we already canceled? ct.ThrowIfCancellationRequested(); ListItemViewModel[] iterable; lock (_listLock) { iterable = Items.ToArray(); } foreach (var item in iterable) { ct.ThrowIfCancellationRequested(); // TODO: GH #502 // We should probably remove the item from the list if it // entered the error state. I had issues doing that without having // multiple threads muck with `Items` (and possibly FilteredItems!) // at once. item.SafeInitializeProperties(); ct.ThrowIfCancellationRequested(); } } /// /// Apply our current filter text to the list of items, and update /// FilteredItems to match the results. /// private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, Filter)); /// /// Helper to generate a weighting for a given list item, based on title, /// subtitle, etc. Largely a copy of the version in ListHelpers, but /// operating on ViewModels instead of extension objects. /// private static int ScoreListItem(string query, CommandItemViewModel listItem) { if (string.IsNullOrEmpty(query)) { return 1; } var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title); var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle); return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); } private struct ScoredListItemViewModel { public int Score; public ListItemViewModel ViewModel; } // Similarly stolen from ListHelpers.FilterList public static IEnumerable FilterList(IEnumerable items, string query) { var scores = items .Where(i => !i.IsInErrorState) .Select(li => new ScoredListItemViewModel() { ViewModel = li, Score = ScoreListItem(query, li) }) .Where(score => score.Score > 0) .OrderByDescending(score => score.Score); return scores .Select(score => score.ViewModel); } // InvokeItemCommand is what this will be in Xaml due to source generator // This is what gets invoked when the user presses [RelayCommand] private void InvokeItem(ListItemViewModel? item) { if (item != null) { WeakReferenceMessenger.Default.Send(new(item.Command.Model, item.Model)); } else if (ShowEmptyContent && EmptyContent.PrimaryCommand?.Model.Unsafe != null) { WeakReferenceMessenger.Default.Send(new( EmptyContent.PrimaryCommand.Command.Model, EmptyContent.PrimaryCommand.Model)); } } // This is what gets invoked when the user presses [RelayCommand] private void InvokeSecondaryCommand(ListItemViewModel? item) { if (item != null) { if (item.SecondaryCommand != null) { WeakReferenceMessenger.Default.Send(new(item.SecondaryCommand.Command.Model, item.Model)); } } else if (ShowEmptyContent && EmptyContent.SecondaryCommand?.Model.Unsafe != null) { WeakReferenceMessenger.Default.Send(new( EmptyContent.SecondaryCommand.Command.Model, EmptyContent.SecondaryCommand.Model)); } } [RelayCommand] private void UpdateSelectedItem(ListItemViewModel? item) { if (_lastSelectedItem != null) { _lastSelectedItem.PropertyChanged -= SelectedItemPropertyChanged; } if (item != null) { SetSelectedItem(item); } else { ClearSelectedItem(); } } private void SetSelectedItem(ListItemViewModel item) { if (!item.SafeSlowInit()) { return; } // GH #322: // For inexplicable reasons, if you try updating the command bar and // the details on the same UI thread tick as updating the list, we'll // explode DoOnUiThread( () => { WeakReferenceMessenger.Default.Send(new(item)); if (ShowDetails && item.HasDetails) { WeakReferenceMessenger.Default.Send(new(item.Details)); } else { WeakReferenceMessenger.Default.Send(); } TextToSuggest = item.TextToSuggest; }); _lastSelectedItem = item; _lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged; } private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) { var item = _lastSelectedItem; if (item == null) { return; } // already on the UI thread here switch (e.PropertyName) { case nameof(item.Command): case nameof(item.SecondaryCommand): case nameof(item.AllCommands): case nameof(item.Name): WeakReferenceMessenger.Default.Send(new(item)); break; case nameof(item.Details): if (ShowDetails && item.HasDetails) { WeakReferenceMessenger.Default.Send(new(item.Details)); } else { WeakReferenceMessenger.Default.Send(); } break; case nameof(item.TextToSuggest): TextToSuggest = item.TextToSuggest; break; } } private void ClearSelectedItem() { // GH #322: // For inexplicable reasons, if you try updating the command bar and // the details on the same UI thread tick as updating the list, we'll // explode DoOnUiThread( () => { WeakReferenceMessenger.Default.Send(new(null)); WeakReferenceMessenger.Default.Send(); TextToSuggest = string.Empty; }); } public override void InitializeProperties() { base.InitializeProperties(); var model = _model.Unsafe; if (model == null) { return; // throw? } _isDynamic = model is IDynamicListPage; ShowDetails = model.ShowDetails; UpdateProperty(nameof(ShowDetails)); _modelPlaceholderText = model.PlaceholderText; UpdateProperty(nameof(PlaceholderText)); InitialSearchText = SearchText = model.SearchText; UpdateProperty(nameof(SearchText)); UpdateProperty(nameof(InitialSearchText)); EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); FetchItems(); model.ItemsChanged += Model_ItemsChanged; } public void LoadMoreIfNeeded() { var model = this._model.Unsafe; if (model == null) { return; } if (model.HasMoreItems && !_isLoading) { _isLoading = true; _ = Task.Run(() => { try { model.LoadMore(); } catch (Exception ex) { ShowException(ex, model.Name); } }); } } protected override void FetchProperty(string propertyName) { base.FetchProperty(propertyName); var model = this._model.Unsafe; if (model == null) { return; // throw? } switch (propertyName) { case nameof(ShowDetails): this.ShowDetails = model.ShowDetails; break; case nameof(PlaceholderText): this._modelPlaceholderText = model.PlaceholderText; break; case nameof(SearchText): this.SearchText = model.SearchText; break; case nameof(EmptyContent): EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent.SlowInitializeProperties(); break; case nameof(IsLoading): UpdateEmptyContent(); break; } UpdateProperty(propertyName); } private void UpdateEmptyContent() { UpdateProperty(nameof(ShowEmptyContent)); if (!ShowEmptyContent || EmptyContent.Model.Unsafe == null) { return; } UpdateProperty(nameof(EmptyContent)); DoOnUiThread( () => { WeakReferenceMessenger.Default.Send(new(EmptyContent)); }); } public void Dispose() { GC.SuppressFinalize(this); _cancellationTokenSource?.Cancel(); _cancellationTokenSource?.Dispose(); _cancellationTokenSource = null; } protected override void UnsafeCleanup() { base.UnsafeCleanup(); EmptyContent?.SafeCleanup(); EmptyContent = new(new(null), PageContext); // necessary? _cancellationTokenSource?.Cancel(); lock (_listLock) { foreach (var item in Items) { item.SafeCleanup(); } Items.Clear(); foreach (var item in FilteredItems) { item.SafeCleanup(); } FilteredItems.Clear(); } var model = _model.Unsafe; if (model != null) { model.ItemsChanged -= Model_ItemsChanged; } } }