// 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 Microsoft.CmdPal.Common.Helpers; using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.UI.ViewModels; public partial class PageViewModel : ExtensionObjectViewModel, IPageContext { public TaskScheduler Scheduler { get; private set; } private readonly ExtensionObject _pageModel; public bool IsLoading => ModelIsLoading || (!IsInitialized); [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsLoading))] public virtual partial bool IsInitialized { get; protected set; } [ObservableProperty] public partial string ErrorMessage { get; protected set; } = string.Empty; /// /// Explicitly: is this page, the VM for the root page. This is used /// slightly differently than being "nested". When we open CmdPal as a /// transient window, we want that page to not have a back button, but that /// page is _not_ the root page. /// /// Later in ListViewModel, we will have logic that checks if it is the root /// page, and modify how selection is handled when the list changes. /// [ObservableProperty] public partial bool IsRootPage { get; set; } = true; /// /// This is used to determine whether to show the back button on this page. /// When a nested page is opened for the transient "dock flyout" window, /// then we don't want to show the back button. /// [ObservableProperty] public partial bool HasBackButton { get; set; } = true; // This is set from the SearchBar [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSuggestion))] public partial string SearchTextBox { get; set; } = string.Empty; [ObservableProperty] public virtual partial string PlaceholderText { get; private set; } = "Type here to search..."; [ObservableProperty] [NotifyPropertyChangedFor(nameof(ShowSuggestion))] public virtual partial string TextToSuggest { get; protected set; } = string.Empty; public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != SearchTextBox; [ObservableProperty] public partial AppExtensionHost ExtensionHost { get; private set; } public bool HasStatusMessage => MostRecentStatusMessage is not null; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasStatusMessage))] public partial StatusMessageViewModel? MostRecentStatusMessage { get; private set; } = null; public ObservableCollection StatusMessages => ExtensionHost.StatusMessages; // These are properties that are "observable" from the extension object // itself, in the sense that they get raised by PropChanged events from the // extension. However, we don't want to actually make them // [ObservableProperty]s, because PropChanged comes in off the UI thread, // and ObservableProperty is not smart enough to raise the PropertyChanged // on the UI thread. public string Name { get; protected set; } = string.Empty; public string Title { get => string.IsNullOrEmpty(field) ? Name : field; protected set; } = string.Empty; public string Id { get; protected set; } = string.Empty; // This property maps to `IPage.IsLoading`, but we want to expose our own // `IsLoading` property as a combo of this value and `IsInitialized` public bool ModelIsLoading { get; protected set; } = true; public bool HasSearchBox { get; protected set; } = true; public bool HasFilters { get; protected set; } public IconInfoViewModel Icon { get; protected set; } public ICommandProviderContext ProviderContext { get; protected set; } public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, ICommandProviderContext providerContext) : base(scheduler) { InitializeSelfAsPageContext(); _pageModel = new(model); Scheduler = scheduler; ExtensionHost = extensionHost; ProviderContext = providerContext; Icon = new(null); ExtensionHost.StatusMessages.CollectionChanged += StatusMessages_CollectionChanged; UpdateHasStatusMessage(); } private void StatusMessages_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) => UpdateHasStatusMessage(); private void UpdateHasStatusMessage() { if (ExtensionHost.StatusMessages.Any()) { var last = ExtensionHost.StatusMessages.Last(); MostRecentStatusMessage = last; } else { MostRecentStatusMessage = null; } } //// Run on background thread from ListPage.xaml.cs [RelayCommand] internal Task InitializeAsync() { // TODO: We may want a SemaphoreSlim lock here. // TODO: We may want to investigate using some sort of AsyncEnumerable or populating these as they come into the UI layer // Though we have to think about threading here and circling back to the UI thread with a TaskScheduler. try { InitializeProperties(); } catch (Exception ex) { ShowException(ex, _pageModel?.Unsafe?.Name); return Task.FromResult(false); } // Notify we're done back on the UI Thread. Task.Factory.StartNew( () => { IsInitialized = true; // TODO: Do we want an event/signal here that the Page Views can listen to? (i.e. ListPage setting the selected index to 0, however, in async world the user may have already started navigating around page...) }, CancellationToken.None, TaskCreationOptions.None, Scheduler); return Task.FromResult(true); } public override void InitializeProperties() { var page = _pageModel.Unsafe; if (page is null) { return; // throw? } Id = page.Id; Name = page.Name; ModelIsLoading = page.IsLoading; Title = page.Title; Icon = new(page.Icon); Icon.InitializeProperties(); HasSearchBox = page is IListPage; // Let the UI know about our initial properties too. UpdateProperty(nameof(Name)); UpdateProperty(nameof(Title)); UpdateProperty(nameof(ModelIsLoading)); UpdateProperty(nameof(IsLoading)); UpdateProperty(nameof(Icon)); UpdateProperty(nameof(HasSearchBox)); page.PropChanged += Model_PropChanged; } private void Model_PropChanged(object sender, IPropChangedEventArgs args) { try { var propName = args.PropertyName; FetchProperty(propName); } catch (Exception ex) { ShowException(ex, _pageModel?.Unsafe?.Name); } } partial void OnSearchTextBoxChanged(string oldValue, string newValue) => OnSearchTextBoxUpdated(newValue); protected virtual void OnSearchTextBoxUpdated(string searchTextBox) { // The base page has no notion of data, so we do nothing here... // subclasses should override. } protected virtual void FetchProperty(string propertyName) { var model = this._pageModel.Unsafe; if (model is null) { return; // throw? } var updateProperty = true; switch (propertyName) { case nameof(Name): this.Name = model.Name ?? string.Empty; UpdateProperty(nameof(Title)); break; case nameof(Title): this.Title = model.Title ?? string.Empty; break; case nameof(IsLoading): this.ModelIsLoading = model.IsLoading; UpdateProperty(nameof(ModelIsLoading)); break; case nameof(Icon): this.Icon = new(model.Icon); break; default: updateProperty = false; break; } // GH #38829: If we always UpdateProperty here, then there's a possible // race condition, where we raise the PropertyChanged(SearchText) // before the subclass actually retrieves the new SearchText from the // model. In that race situation, if the UI thread handles the // PropertyChanged before ListViewModel fetches the SearchText, it'll // think that the old search text is the _new_ value. if (updateProperty) { UpdateProperty(propertyName); } } public new void ShowException(Exception ex, string? extensionHint = null) { // Set the extensionHint to the Page Title (if we have one, and one not provided). // extensionHint ??= _pageModel?.Unsafe?.Title; extensionHint ??= ExtensionHost.GetExtensionDisplayName() ?? Title; Task.Factory.StartNew( () => { var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint); ErrorMessage += message; }, CancellationToken.None, TaskCreationOptions.None, Scheduler); } public override string ToString() => $"{Title} ViewModel"; protected override void UnsafeCleanup() { base.UnsafeCleanup(); ExtensionHost.StatusMessages.CollectionChanged -= StatusMessages_CollectionChanged; var model = _pageModel.Unsafe; if (model is not null) { model.PropChanged -= Model_PropChanged; } } } public interface IPageContext { void ShowException(Exception ex, string? extensionHint = null); TaskScheduler Scheduler { get; } ICommandProviderContext ProviderContext { get; } } public interface IPageViewModelFactoryService { /// /// Creates a new instance of the page view model for the given page type. /// /// The page for which to create the view model. /// Indicates whether the page is not the top-level page. /// The command palette host that will host the page (for status messages) /// A new instance of the page view model. PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext); }