diff --git a/src/modules/cmdpal/Exts/HackerNewsExtension/Pages/HackerNewsPage.cs b/src/modules/cmdpal/Exts/HackerNewsExtension/Pages/HackerNewsPage.cs index 3e0b2911cf04..4687a209d5c7 100644 --- a/src/modules/cmdpal/Exts/HackerNewsExtension/Pages/HackerNewsPage.cs +++ b/src/modules/cmdpal/Exts/HackerNewsExtension/Pages/HackerNewsPage.cs @@ -2,6 +2,7 @@ // 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; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -23,6 +24,8 @@ public HackerNewsPage() Icon = new("https://news.ycombinator.com/favicon.ico"); Name = "Hacker News"; AccentColor = Color.FromArgb(255, 255, 102, 0); + Loading = true; + ShowDetails = true; } private static async Task> GetHackerNewsTopPosts() @@ -49,9 +52,29 @@ private static async Task> GetHackerNewsTopPosts() public override IListItem[] GetItems() { - var t = DoGetItems(); - t.ConfigureAwait(false); - return t.Result; + try + { + Loading = true; + var t = DoGetItems(); + t.ConfigureAwait(false); + return t.Result; + } + catch (Exception ex) + { + return [ + new ListItem(new NoOpCommand()) { Title = "Exception getting posts from HN" }, + new ListItem(new NoOpCommand()) + { + Title = $"{ex.HResult}", + Subtitle = ex.HResult == -2147023174 ? "This is probably zadjii-msft/PowerToys#181" : string.Empty, + }, + new ListItem(new NoOpCommand()) + { + Title = "Stack trace", + Details = new Details() { Body = $"```{ex.Source}\n{ex.StackTrace}```" }, + }, + ]; + } } private async Task DoGetItems() diff --git a/src/modules/cmdpal/Exts/MastodonExtension/MastodonExtensionPage.cs b/src/modules/cmdpal/Exts/MastodonExtension/MastodonExtensionPage.cs index 0e133c98d00c..4d2ac26ebb44 100644 --- a/src/modules/cmdpal/Exts/MastodonExtension/MastodonExtensionPage.cs +++ b/src/modules/cmdpal/Exts/MastodonExtension/MastodonExtensionPage.cs @@ -24,7 +24,7 @@ internal sealed partial class MastodonExtensionPage : ListPage internal static readonly HttpClient Client = new(); internal static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true }; - private readonly List _items = new(); + private readonly List _items = []; public MastodonExtensionPage() { @@ -32,6 +32,7 @@ public MastodonExtensionPage() Name = "Mastodon"; ShowDetails = true; HasMore = true; + Loading = true; // #6364ff AccentColor = Color.FromArgb(255, 99, 100, 255); @@ -104,10 +105,7 @@ public override void LoadMore() }).ConfigureAwait(false); } - public async Task> FetchExplorePage() - { - return await FetchExplorePage(20, 0); - } + public async Task> FetchExplorePage() => await FetchExplorePage(20, 0); public async Task> FetchExplorePage(int limit, int offset) { @@ -129,6 +127,8 @@ public async Task> FetchExplorePage(int limit, int offset) Console.WriteLine($"An error occurred: {e.Message}"); } + Loading = false; + return statuses; } } @@ -145,10 +145,7 @@ public MastodonExtensionActionsProvider() new CommandItem(new MastodonExtensionPage()) { Subtitle = "Explore top posts on mastodon.social" }, ]; - public override ICommandItem[] TopLevelCommands() - { - return _actions; - } + public override ICommandItem[] TopLevelCommands() => _actions; } [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] @@ -177,10 +174,7 @@ public string DataJson() public string StateJson() => throw new NotImplementedException(); - public ICommandResult SubmitForm(string payload) - { - return CommandResult.Dismiss(); - } + public ICommandResult SubmitForm(string payload) => CommandResult.Dismiss(); public string TemplateJson() { @@ -404,10 +398,7 @@ private static string ParseNodeToMarkdown(HtmlNode node, bool escapeHashtags) } } - private static string ParseNodeToPlaintext(HtmlNode node) - { - return node.InnerText; - } + private static string ParseNodeToPlaintext(HtmlNode node) => node.InnerText; } [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] diff --git a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/launchSettings.json b/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/launchSettings.json index 7adb50094cd2..f9566c15527d 100644 --- a/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/launchSettings.json +++ b/src/modules/cmdpal/Exts/ProcessMonitorExtension/Properties/launchSettings.json @@ -1,10 +1,11 @@ { "profiles": { "ProcessMonitorExtension (Package)": { - "commandName": "MsixPackage" + "commandName": "MsixPackage", + "doNotLaunchApp": true }, "ProcessMonitorExtension (Unpackaged)": { "commandName": "Project" } } -} +} \ No newline at end of file diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSampleListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSampleListPage.cs new file mode 100644 index 000000000000..6fb2420d2acb --- /dev/null +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSampleListPage.cs @@ -0,0 +1,32 @@ +// 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.Timers; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +internal sealed partial class EvilSampleListPage : ListPage +{ + public EvilSampleListPage() + { + Icon = new(string.Empty); + Name = "Open"; + } + + public override IListItem[] GetItems() + { + IListItem[] commands = [ + new ListItem(new EvilSampleListPage()) + { + Subtitle = "Doesn't matter, I'll blow up before you see this", + }, + ]; + + _ = commands[9001]; // Throws + + return commands; + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSamplesPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSamplesPage.cs new file mode 100644 index 000000000000..b5f71819a232 --- /dev/null +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/EvilSamplesPage.cs @@ -0,0 +1,42 @@ +// 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 Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +public partial class EvilSamplesPage : ListPage +{ + private readonly IListItem[] _commands = [ + new ListItem(new EvilSampleListPage()) + { + Title = "List Page without items", + Subtitle = "Throws exception on GetItems", + }, + new ListItem(new ExplodeInFiveSeconds(false)) + { + Title = "Page that will throw an exception after loading it", + Subtitle = "Throws exception on GetItems _after_ a ItemsChanged", + }, + new ListItem(new ExplodeInFiveSeconds(true)) + { + Title = "Page that keeps throwing exceptions", + Subtitle = "Will throw every 5 seconds once you open it", + }, + new ListItem(new SelfImmolateCommand()) + { + Title = "Terminate this extension", + Subtitle = "Will exit this extension (while it's loaded!)", + }, + ]; + + public EvilSamplesPage() + { + Name = "Evil Samples"; + Icon = new("👿"); // Info + } + + public override IListItem[] GetItems() => _commands; +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/ExplodeInFiveSeconds.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/ExplodeInFiveSeconds.cs new file mode 100644 index 000000000000..60207c2750dc --- /dev/null +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/ExplodeInFiveSeconds.cs @@ -0,0 +1,50 @@ +// 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.Timers; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +internal sealed partial class ExplodeInFiveSeconds : ListPage +{ + private readonly bool _repeat; + + private IListItem[] Commands => [ + new ListItem(new NoOpCommand()) + { + Title = "This page will explode in five seconds!", + Subtitle = _repeat ? "Not only that, I'll _keep_ exploding every 5 seconds after that" : string.Empty, + }, + ]; + + private bool shouldExplode; + private static Timer timer; + + public ExplodeInFiveSeconds(bool repeat) + { + _repeat = repeat; + Icon = new(string.Empty); + Name = "Open"; + } + + public override IListItem[] GetItems() + { + if (shouldExplode) + { + _ = Commands[9001]; // Throws + } + else + { + timer = new Timer(5000); + timer.Elapsed += (object source, ElapsedEventArgs e) => { RaiseItemsChanged(9000); }; + timer.AutoReset = _repeat; // Keep repeating + timer.Enabled = true; + } + + shouldExplode = true; + return Commands; + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs index e0b63517aaad..226c4635909f 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleDynamicListPage.cs @@ -2,19 +2,10 @@ // 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; -using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Net.Http; -using System.Runtime.InteropServices; using System.Runtime.InteropServices.WindowsRuntime; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using System.Xml.Linq; using Microsoft.CmdPal.Extensions; using Microsoft.CmdPal.Extensions.Helpers; -using Microsoft.UI.Windowing; namespace SamplePagesExtension; @@ -24,12 +15,10 @@ public SampleDynamicListPage() { Icon = new(string.Empty); Name = "Dynamic List"; + Loading = true; } - public override void UpdateSearchText(string oldSearch, string newSearch) - { - RaiseItemsChanged(newSearch.Length); - } + public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(newSearch.Length); public override IListItem[] GetItems() { diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SampleUpdatingItemsPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/SampleUpdatingItemsPage.cs new file mode 100644 index 000000000000..18e394b253a3 --- /dev/null +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/SampleUpdatingItemsPage.cs @@ -0,0 +1,43 @@ +// 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.Timers; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; +using Microsoft.VisualBasic; + +namespace SamplePagesExtension; + +public partial class SampleUpdatingItemsPage : ListPage +{ + private readonly ListItem hourItem = new(new NoOpCommand()); + private readonly ListItem minuteItem = new(new NoOpCommand()); + private readonly ListItem secondItem = new(new NoOpCommand()); + private static Timer timer; + + public SampleUpdatingItemsPage() + { + Name = "Open"; + Icon = new(string.Empty); + } + + public override IListItem[] GetItems() + { + if (timer == null) + { + timer = new Timer(500); + timer.Elapsed += (object source, ElapsedEventArgs e) => + { + var current = DateAndTime.Now; + hourItem.Title = $"{current.Hour}"; + minuteItem.Title = $"{current.Minute}"; + secondItem.Title = $"{current.Second}"; + }; + timer.AutoReset = true; // Keep repeating + timer.Enabled = true; + } + + return [hourItem, minuteItem, secondItem]; + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs index ca4efa27f778..19a9f4895ff5 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs @@ -30,6 +30,11 @@ public partial class SamplesListPage : ListPage Title = "List Page With Details Sample Command", Subtitle = "A list of items, each with additional details to display", }, + new ListItem(new SampleUpdatingItemsPage()) + { + Title = "List page with items that change", + Subtitle = "The items on the list update themselves in real time", + }, new ListItem(new SampleDynamicListPage()) { Title = "Dynamic List Page Command", @@ -39,6 +44,11 @@ public partial class SamplesListPage : ListPage { Title = "Sample settings page", Subtitle = "A demo of the settings helpers", + }, + new ListItem(new EvilSamplesPage()) + { + Title = "Evil samples", + Subtitle = "Samples designed to break the palette in many different evil ways", } ]; @@ -48,8 +58,5 @@ public SamplesListPage() Icon = new("\ue946"); // Info } - public override IListItem[] GetItems() - { - return _commands; - } + public override IListItem[] GetItems() => _commands; } diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs new file mode 100644 index 000000000000..9a5927e49f34 --- /dev/null +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs @@ -0,0 +1,18 @@ +// 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.Diagnostics; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +public partial class SelfImmolateCommand : InvokableCommand +{ + public override ICommandResult Invoke() + { + Process.GetCurrentProcess().Kill(); + return base.Invoke(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs index 2153826f79f8..9ca1415a7475 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs @@ -10,7 +10,9 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public partial class ActionBarViewModel : ObservableObject +public partial class ActionBarViewModel : ObservableObject, + IRecipient, + IRecipient { public ListItemViewModel? SelectedItem { @@ -31,13 +33,20 @@ public ListItemViewModel? SelectedItem [ObservableProperty] public partial bool ShouldShowContextMenu { get; set; } = false; + [ObservableProperty] + public partial PageViewModel? CurrentPage { get; private set; } + [ObservableProperty] public partial ObservableCollection ContextActions { get; set; } = []; public ActionBarViewModel() { + WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); } + public void Receive(UpdateActionBarMessage message) => SelectedItem = message.ViewModel; + private void SetSelectedItem(ListItemViewModel? value) { if (value != null) @@ -45,7 +54,7 @@ private void SetSelectedItem(ListItemViewModel? value) PrimaryActionName = value.Name; SecondaryActionName = value.SecondaryCommandName; - if (value.MoreCommands.Count > 0) + if (value.MoreCommands.Count > 1) { ShouldShowContextMenu = true; ContextActions = [.. value.AllCommands]; @@ -66,4 +75,6 @@ private void SetSelectedItem(ListItemViewModel? value) // InvokeItemCommand is what this will be in Xaml due to source generator [RelayCommand] private void InvokeItem(CommandContextItemViewModel item) => WeakReferenceMessenger.Default.Send(new(item.Command)); + + public void Receive(UpdateActionBarPage message) => CurrentPage = message.Page; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs index daafc83f9f24..361c21287f4c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs @@ -7,7 +7,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public partial class CommandContextItemViewModel(ICommandContextItem contextItem) : CommandItemViewModel(new(contextItem)) +public partial class CommandContextItemViewModel(ICommandContextItem contextItem, TaskScheduler scheduler) : CommandItemViewModel(new(contextItem), scheduler) { private readonly ExtensionObject _contextItemModel = new(contextItem); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 34725c68b9f3..c13e4123fc90 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -2,18 +2,24 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using CommunityToolkit.Mvvm.ComponentModel; using Microsoft.CmdPal.Extensions; using Microsoft.CmdPal.Extensions.Helpers; using Microsoft.CmdPal.UI.ViewModels.Models; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class CommandItemViewModel : ObservableObject +public partial class CommandItemViewModel : ExtensionObjectViewModel { private readonly ExtensionObject _commandItemModel = new(null); - // private bool _initialized; + protected TaskScheduler Scheduler { get; private set; } + + // 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 raisee the PropertyChanged + // on the UI thread. public string Name { get; private set; } = string.Empty; public string Title { get; private set; } = string.Empty; @@ -38,7 +44,7 @@ public List AllCommands var model = new CommandContextItem(command!) { }; - CommandContextItemViewModel defaultCommand = new(model) + CommandContextItemViewModel defaultCommand = new(model, Scheduler) { Name = Name, Title = Name, @@ -54,13 +60,14 @@ public List AllCommands } } - public CommandItemViewModel(ExtensionObject item) + public CommandItemViewModel(ExtensionObject item, TaskScheduler scheduler) { _commandItemModel = item; + Scheduler = scheduler; } //// Called from ListViewModel on background thread started in ListPage.xaml.cs - public virtual void InitializeProperties() + public override void InitializeProperties() { var model = _commandItemModel.Unsafe; if (model == null) @@ -76,7 +83,7 @@ public virtual void InitializeProperties() MoreCommands = model.MoreCommands .Where(contextItem => contextItem is ICommandContextItem) .Select(contextItem => (contextItem as ICommandContextItem)!) - .Select(contextItem => new CommandContextItemViewModel(contextItem)) + .Select(contextItem => new CommandContextItemViewModel(contextItem, Scheduler)) .ToList(); // Here, we're already theoretically in the async context, so we can @@ -96,7 +103,6 @@ private void Model_PropChanged(object sender, PropChangedEventArgs args) try { FetchProperty(args.PropertyName); - OnPropertyChanged(args.PropertyName); } catch (Exception) { @@ -127,5 +133,9 @@ protected virtual void FetchProperty(string propertyName) // TODO! Icon // TODO! MoreCommands array, which needs to also raise HasMoreCommands } + + UpdateProperty(propertyName); } + + protected void UpdateProperty(string propertyName) => Task.Factory.StartNew(() => { OnPropertyChanged(propertyName); }, CancellationToken.None, TaskCreationOptions.None, Scheduler); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs new file mode 100644 index 000000000000..bf2ca7b6cb6f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ExtensionObjectViewModel.cs @@ -0,0 +1,29 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public abstract partial class ExtensionObjectViewModel : ObservableObject +{ + public async virtual Task InitializePropertiesAsync() + { + var t = new Task(() => + { + try + { + InitializeProperties(); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine(ex); + } + }); + t.Start(); + await t; + } + + public abstract void InitializeProperties(); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs index a8d3dfe0df5a..9ecbf48b96e7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs @@ -7,10 +7,13 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public partial class ListItemViewModel(IListItem model) : CommandItemViewModel(new(model)) +public partial class ListItemViewModel(IListItem model, TaskScheduler scheduler) + : CommandItemViewModel(new(model), scheduler) { private readonly ExtensionObject _listItemModel = new(model); + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] public ITag[] Tags { get; private set; } = []; public bool HasTags => Tags.Length > 0; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index fca7e62b7009..3cff9dfde335 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -12,19 +12,17 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public partial class ListViewModel : ObservableObject +public partial class ListViewModel : PageViewModel { // 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 ObservableGroupedCollection Items { get; set; } = []; - [ObservableProperty] - public partial bool IsInitialized { get; private set; } - private readonly ExtensionObject _model; - public ListViewModel(IListPage model) + public ListViewModel(IListPage model, TaskScheduler scheduler) + : base(model, scheduler) { _model = new(model); } @@ -39,37 +37,28 @@ private void FetchItems() // TEMPORARY: just plop all the items into a single group // see 9806fe5d8 for the last commit that had this with sections // TODO unsafe - var newItems = _model.Unsafe!.GetItems(); - - Items.Clear(); - - foreach (var item in newItems) + try { - ListItemViewModel viewModel = new(item); - viewModel.InitializeProperties(); - group.Add(viewModel); - } - - // Am I really allowed to modify that observable collection on a BG - // thread and have it just work in the UI?? - Items.AddGroup(group); - } - - //// Run on background thread from ListPage.xaml.cs - [RelayCommand] - private 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 in to the UI layer - // Though we have to think about threading here and circling back to the UI thread with a TaskScheduler. - FetchItems(); + var newItems = _model.Unsafe!.GetItems(); - _model.Unsafe!.ItemsChanged += Model_ItemsChanged; + Items.Clear(); - IsInitialized = true; + foreach (var item in newItems) + { + ListItemViewModel viewModel = new(item, Scheduler); + viewModel.InitializeProperties(); + group.Add(viewModel); + } - return Task.FromResult(true); + // Am I really allowed to modify that observable collection on a BG + // thread and have it just work in the UI?? + Items.AddGroup(group); + } + catch (Exception ex) + { + ShowException(ex); + throw; + } } // InvokeItemCommand is what this will be in Xaml due to source generator @@ -78,4 +67,18 @@ private Task InitializeAsync() [RelayCommand] private void UpdateSelectedItem(ListItemViewModel item) => WeakReferenceMessenger.Default.Send(new(item)); + + public override void InitializeProperties() + { + base.InitializeProperties(); + + var listPage = _model.Unsafe; + if (listPage == null) + { + return; // throw? + } + + FetchItems(); + listPage.ItemsChanged += Model_ItemsChanged; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainPage/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainPage/MainListPage.cs index 0d71a8ad6ae0..391f0e4196e3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainPage/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MainPage/MainListPage.cs @@ -23,6 +23,7 @@ public partial class MainListPage : DynamicListPage public MainListPage(IServiceProvider serviceProvider) { + Name = "Command Palette"; _serviceProvider = serviceProvider; var tlcManager = _serviceProvider.GetService(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateToListMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateToListMessage.cs deleted file mode 100644 index 4b5bc5665305..000000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/NavigateToListMessage.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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. - -namespace Microsoft.CmdPal.UI.ViewModels.Messages; - -// Want to know what a record is? here is a TLDR -// https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record - -/// -/// Message to instruct UI to navigate to a list page. -/// -/// The for the list page to navigate to. -public record NavigateToListMessage(ListViewModel ViewModel) -{ -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowExceptionMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowExceptionMessage.cs new file mode 100644 index 000000000000..6d8b0b544635 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ShowExceptionMessage.cs @@ -0,0 +1,9 @@ +// 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. + +namespace Microsoft.CmdPal.UI.ViewModels.Messages; + +public record ShowExceptionMessage(Exception Exception) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarMessage.cs index c47841d3ef87..7fcad1cff966 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarMessage.cs @@ -7,6 +7,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Messages; /// /// Used to update the action bar at the bottom to refelct the commands for a list item /// -public record UpdateActionBarMessage(ListItemViewModel ViewModel) +public record UpdateActionBarMessage(ListItemViewModel? ViewModel) { } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarPage.cs new file mode 100644 index 000000000000..84a1e0edc0ee --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateActionBarPage.cs @@ -0,0 +1,9 @@ +// 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. + +namespace Microsoft.CmdPal.UI.ViewModels.Messages; + +public record UpdateActionBarPage(PageViewModel? Page) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs new file mode 100644 index 000000000000..76c94436a5fc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs @@ -0,0 +1,129 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class PageViewModel : ExtensionObjectViewModel +{ + protected TaskScheduler Scheduler { get; private set; } + + private readonly ExtensionObject _pageModel; + + [ObservableProperty] + public partial bool IsInitialized { get; private set; } + + [ObservableProperty] + public partial string ErrorMessage { get; private set; } = string.Empty; + + // 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 raisee the PropertyChanged + // on the UI thread. + public string Name { get; private set; } = string.Empty; + + public bool Loading { get; private set; } = true; + + public PageViewModel(IPage model, TaskScheduler scheduler) + { + _pageModel = new(model); + Scheduler = scheduler; + } + + //// Run on background thread from ListPage.xaml.cs + [RelayCommand] + private 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 in to 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) + { + return Task.FromResult(false); + } + + IsInitialized = true; + return Task.FromResult(true); + } + + public override void InitializeProperties() + { + var page = _pageModel.Unsafe; + if (page == null) + { + return; // throw? + } + + Name = page.Name; + Loading = page.Loading; + + // Let the UI know about our initial properties too. + UpdateProperty(nameof(Name)); + UpdateProperty(nameof(Loading)); + + page.PropChanged += Model_PropChanged; + } + + private void Model_PropChanged(object sender, PropChangedEventArgs args) + { + try + { + var propName = args.PropertyName; + FetchProperty(propName); + } + catch (Exception) + { + // TODO log? throw? + } + } + + protected virtual void FetchProperty(string propertyName) + { + var model = this._pageModel.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(Name): + this.Name = model.Name ?? string.Empty; + break; + case nameof(Loading): + this.Loading = model.Loading; + break; + } + + UpdateProperty(propertyName); + } + + protected void UpdateProperty(string propertyName) => Task.Factory.StartNew(() => { OnPropertyChanged(propertyName); }, CancellationToken.None, TaskCreationOptions.None, Scheduler); + + protected void ShowException(Exception ex) + { + Task.Factory.StartNew( + () => + { + ErrorMessage = $"{ex.Message}\n{ex.Source}\n{ex.StackTrace}\n\nThis is due to a bug in the extension's code."; + WeakReferenceMessenger.Default.Send(new(ex)); + }, + CancellationToken.None, + TaskCreationOptions.None, + Scheduler); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs index 52ea49e428ab..c8377a173976 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs @@ -25,7 +25,7 @@ public async Task LoadAsync() // Built-ins have loaded. We can display our page at this point. var page = new MainListPage(_serviceProvider); - WeakReferenceMessenger.Default.Send(new(new(page!))); + WeakReferenceMessenger.Default.Send(new(new(page!))); // After loading built-ins, and starting navigation, kick off a thread to load extensions. tlcManager.LoadExtensionsCommand.Execute(null); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index af8018177174..2d361ac46777 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -11,6 +11,7 @@ using Microsoft.CmdPal.Ext.WindowsSettings; using Microsoft.CmdPal.Ext.WindowsTerminal; using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.UI.Pages; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands; using Microsoft.CmdPal.UI.ViewModels.Models; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml index 50180ee4df80..dd38f8d8e347 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml @@ -63,7 +63,7 @@ Grid.Column="1" VerticalAlignment="Center" FontSize="12" - Text="Plugin name" /> + Text="{x:Bind ViewModel.CurrentPage.Name, Mode=OneWay}" /> + Text="Ctrl + ↲" /> diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml.cs index 0110ededdb5f..7dbc5d4de9ff 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ActionBar.xaml.cs @@ -2,23 +2,19 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; namespace Microsoft.CmdPal.UI.Controls; -public sealed partial class ActionBar : UserControl, - IRecipient +public sealed partial class ActionBar : UserControl { public ActionBarViewModel ViewModel { get; set; } = new(); public ActionBar() { this.InitializeComponent(); - WeakReferenceMessenger.Default.Register(this); } private void ActionListViewItem_KeyDown(object sender, KeyRoutedEventArgs e) @@ -40,6 +36,4 @@ private void ActionListViewItem_Tapped(object sender, TappedRoutedEventArgs e) ViewModel?.InvokeItemCommand.Execute(item); } } - - public void Receive(UpdateActionBarMessage message) => ViewModel.SelectedItem = message.ViewModel; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 06f88d36c648..608736ab0816 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -81,6 +81,10 @@ + - + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index b2bc0c850b29..03275d0c40c0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -2,6 +2,7 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. +using CommunityToolkit.Common; using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.WinUI; using Microsoft.CmdPal.UI.ViewModels; @@ -19,7 +20,8 @@ namespace Microsoft.CmdPal.UI; public sealed partial class ListPage : Page, IRecipient, IRecipient, - IRecipient + IRecipient, + IRecipient { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -57,10 +59,17 @@ protected override void OnNavigatedTo(NavigationEventArgs e) && lvm.InitializeCommand != null) { ViewModel = null; - lvm.InitializeCommand.Execute(null); _ = Task.Run(async () => { + // You know, this creates the situation where we wait for + // both loading page properties, AND the items, before we + // display anything. + // + // We almost need to do an async await on initialize, then + // just a fire-and-forget on FetchItems. + lvm.InitializeCommand.Execute(null); + await lvm.InitializeCommand.ExecutionTask!; if (lvm.InitializeCommand.ExecutionTask.Status != TaskStatus.RanToCompletion) @@ -77,8 +86,11 @@ protected override void OnNavigatedTo(NavigationEventArgs e) { _ = _queue.EnqueueAsync(() => { + var result = (bool)lvm.InitializeCommand.ExecutionTask.GetResultOrDefault()!; + ViewModel = lvm; - LoadedState = ViewModelLoadedState.Loaded; + WeakReferenceMessenger.Default.Send(new(result ? lvm : null)); + LoadedState = result ? ViewModelLoadedState.Loaded : ViewModelLoadedState.Error; }); } }); @@ -86,6 +98,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e) else { ViewModel = lvm; + WeakReferenceMessenger.Default.Send(new(lvm)); LoadedState = ViewModelLoadedState.Loaded; } } @@ -94,6 +107,7 @@ protected override void OnNavigatedTo(NavigationEventArgs e) WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); base.OnNavigatedTo(e); } @@ -105,6 +119,7 @@ protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); WeakReferenceMessenger.Default.Unregister(this); + WeakReferenceMessenger.Default.Unregister(this); } private void ListView_ItemClick(object sender, ItemClickEventArgs e) @@ -152,6 +167,14 @@ private void ItemsList_SelectionChanged(object sender, SelectionChangedEventArgs ViewModel?.UpdateSelectedItemCommand.Execute(item); } } + + public void Receive(ShowExceptionMessage message) + { + _ = _queue.EnqueueAsync(() => + { + LoadedState = ViewModelLoadedState.Error; + }); + } } public enum ViewModelLoadedState diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs index 5ac0ce4daa43..cf2c9a41af93 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml.cs @@ -4,6 +4,7 @@ using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.UI.Pages; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.Extensions.DependencyInjection; @@ -19,7 +20,6 @@ public sealed partial class ShellPage : Page, IRecipient, IRecipient, - IRecipient, IRecipient { private readonly DrillInNavigationTransitionInfo _drillInNavigationTransitionInfo = new(); @@ -35,7 +35,6 @@ public ShellPage() // how we are doing navigation around WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); RootFrame.Navigate(typeof(LoadingPage), ViewModel); @@ -48,25 +47,11 @@ public void Receive(NavigateBackMessage message) if (RootFrame.CanGoBack) { RootFrame.GoBack(); + RootFrame.ForwardStack.Clear(); + SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); } } - public void Receive(NavigateToListMessage message) - { - // The first time we navigate to a list (from loading -> main list), - // clear out the back stack so that we can't go back again. - var fromLoading = RootFrame.CanGoBack; - - RootFrame.Navigate(typeof(ListPage), message.ViewModel, _slideRightTransition); - - if (!fromLoading) - { - RootFrame.BackStack.Clear(); - } - - SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); - } - public void Receive(PerformCommandMessage message) { var command = message.Command.Unsafe; @@ -84,9 +69,19 @@ public void Receive(PerformCommandMessage message) { if (command is IListPage listPage) { - var pageViewModel = new ListViewModel(listPage); - RootFrame.Navigate(typeof(ListPage), pageViewModel, _slideRightTransition); - SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + _ = DispatcherQueue.TryEnqueue(() => + { + var pageViewModel = new ListViewModel(listPage, TaskScheduler.FromCurrentSynchronizationContext()); + RootFrame.Navigate(typeof(ListPage), pageViewModel, _slideRightTransition); + SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); + if (command is MainListPage) + { + // todo bodgy + RootFrame.BackStack.Clear(); + } + + WeakReferenceMessenger.Default.Send(new(pageViewModel)); + }); } // else if markdown, forms, TODO diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/BaseObservable.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/BaseObservable.cs index 459fe9a9bdcb..726e9b336216 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/BaseObservable.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/BaseObservable.cs @@ -16,9 +16,16 @@ public class BaseObservable : INotifyPropChanged protected void OnPropertyChanged(string propertyName) { - if (PropChanged != null) + try + { + // TODO #181 - This is dangerous! If the original host goes away, + // this can crash as we try to invoke the handlers from that process. + // However, just catching it seems to still raise the event on the + // new host? + PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName)); + } + catch { - PropChanged.Invoke(this, new PropChangedEventArgs(propertyName)); } } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ListPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ListPage.cs index 7440de747dda..081d64486b83 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ListPage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ListPage.cs @@ -85,9 +85,13 @@ public virtual void LoadMore() protected void RaiseItemsChanged(int totalItems) { - if (ItemsChanged != null) + try + { + // TODO #181 - This is the same thing that BaseObservable has to deal with. + ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + } + catch { - ItemsChanged.Invoke(this, new ItemsChangedEventArgs(totalItems)); } } }