From edb61457f459552c4e613a2b54bd27ff7bc0372f Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Mon, 3 Feb 2025 16:30:46 -0600 Subject: [PATCH] BREAKING: Add ContentPages and Invoke(sender) (#395) ## BREAKING API CHANGES APPROACHING Closes #307 Closes #238 Removes `Command` from `ITag` Adds `IDetailsCommand` (to achieve the same goal as the above originally had) Adds `ITreeContent`, based on a hunch Adds `ShowToast` and `Confirm` to `CommandResult` too, but without UX yet Extensions from before this change will need to be updated. * The `InvokableCommand` change should be trivial - the helpers should abstract that delta away for you. * The `ContentPage` change should be pretty easy to make. * Both `MarkdownPage` and `FormPage` are now just `ContentPage` * `FormPage.Forms()` -> `ContentPage.GetContent()` * `MarkdownPage.Bodies()` -> `ContentPage.GetContent()` * `IForm`s become `IFormContent`. Methods become properties (not that bad) * I'm only deprecating the old Markdown and Form pages - I'll fully remove them before we OSS, but I'll give everyone a couple weeks to port them. * No one was using `Tag.Command` and it always seemed iffy at best - better not. --- .../Pages/PokedexExtensionPage.cs | 51 +- .../Pages/SampleContentPage.cs | 338 ++++++++++++++ .../Pages/SampleListPage.cs | 2 +- .../Pages/SampleListPageWithDetails.cs | 4 +- .../SamplePagesExtension/SamplesListPage.cs | 126 +++-- .../SelfImmolateCommand.cs | 2 +- .../ActionBarViewModel.cs | 3 +- .../CommandContextItemViewModel.cs | 4 +- .../CommandItemViewModel.cs | 5 +- .../ContentFormViewModel.cs | 145 ++++++ .../ContentMarkdownViewModel.cs | 68 +++ .../ContentPageViewModel.cs | 166 +++++++ .../ContentTreeViewModel.cs | 130 ++++++ .../ContentViewModel.cs | 10 + .../ListItemViewModel.cs | 10 +- .../ListViewModel.cs | 7 +- .../MarkdownPageViewModel.cs | 3 +- .../Messages/PerformCommandMessage.cs | 23 +- .../TagViewModel.cs | 1 - .../ContentTemplateSelector.xaml.cs | 37 ++ .../Controls/ContentFormControl.xaml | 25 + .../Controls/ContentFormControl.xaml.cs | 88 ++++ .../ExtViews/ContentPage.xaml | 138 ++++++ .../ExtViews/ContentPage.xaml.cs | 46 ++ .../ExtViews/MarkdownPage.xaml.cs | 3 - .../Pages/ShellPage.xaml.cs | 5 +- .../Views/MainPage.xaml.cs | 17 +- .../initial-sdk-spec/generate-interface.ps1 | 10 +- .../doc/initial-sdk-spec/initial-sdk-spec.md | 440 +++++++++++------- .../CommandResult.cs | 63 ++- .../ConfirmationArgs.cs | 16 + .../ContentPage.cs | 38 ++ .../DetailsCommand.cs | 10 + .../DetailsLink.cs | 19 + .../Form.cs | 2 +- .../FormContent.cs | 46 ++ .../GoToPageArgs.cs | 2 +- .../InvokableCommand.cs | 6 +- .../MarkdownContent.cs | 20 + ...Microsoft.CmdPal.Extensions.Helpers.csproj | 1 + .../StatusMessage.cs | 22 +- .../Tag.cs | 23 +- .../ToastArgs.cs | 12 + .../ToastStatusMessage.cs | 47 ++ .../TreeContent.cs | 38 ++ .../Microsoft.CmdPal.Extensions.idl | 67 ++- .../PokedexExtension/GlobalSuppressions1.cs | 8 + .../Pages/SampleCommentsPage.cs | 181 +++++++ 48 files changed, 2202 insertions(+), 326 deletions(-) create mode 100644 src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/ContentTemplateSelector.xaml.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ConfirmationArgs.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ContentPage.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsCommand.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/FormContent.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/MarkdownContent.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastArgs.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastStatusMessage.cs create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/TreeContent.cs create mode 100644 src/modules/cmdpal/exts/PokedexExtension/GlobalSuppressions1.cs create mode 100644 src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleCommentsPage.cs diff --git a/src/modules/cmdpal/Exts/PokedexExtension/Pages/PokedexExtensionPage.cs b/src/modules/cmdpal/Exts/PokedexExtension/Pages/PokedexExtensionPage.cs index ec68eefbba..7bf2a93ac0 100644 --- a/src/modules/cmdpal/Exts/PokedexExtension/Pages/PokedexExtensionPage.cs +++ b/src/modules/cmdpal/Exts/PokedexExtension/Pages/PokedexExtensionPage.cs @@ -3,14 +3,13 @@ // See the LICENSE file in the project root for more information. using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Microsoft.CmdPal.Extensions; using Microsoft.CmdPal.Extensions.Helpers; namespace PokedexExtension; -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "This is sample code")] public class Pokemon { public int Number { get; set; } @@ -21,6 +20,8 @@ public class Pokemon public string IconUrl => $"https://serebii.net/pokedex-sv/icon/new/{Number:D3}.png"; + public string SerebiiUrl => $"https://serebii.net/pokedex-sv/{Number:D3}.shtml"; + public Pokemon(int number, string name, List types) { Number = number; @@ -29,17 +30,42 @@ public class Pokemon } } -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] -internal sealed partial class PokemonPage : NoOpCommand +internal sealed partial class OpenPokemonCommand : InvokableCommand { - public PokemonPage(Pokemon pokemon) + public OpenPokemonCommand() { - Name = pokemon.Name; - Icon = new(pokemon.IconUrl); + Name = "Open"; + } + + public override ICommandResult Invoke(object sender) + { + if (sender is PokemonListItem item) + { + var pokemon = item.Pokemon; + Process.Start(new ProcessStartInfo(pokemon.SerebiiUrl) { UseShellExecute = true }); + } + + return CommandResult.KeepOpen(); + } +} + +internal sealed partial class PokemonListItem : ListItem +{ + private static readonly OpenPokemonCommand _command = new(); + + public Pokemon Pokemon { get; private set; } + + public PokemonListItem(Pokemon p) + : base(_command) + { + Pokemon = p; + Title = Pokemon.Name; + Icon = new(Pokemon.IconUrl); + Subtitle = $"#{Pokemon.Number}"; + Tags = Pokemon.Types.Select(t => new Tag() { Text = t, Background = PokedexExtensionPage.GetColorForType(t) }).ToArray(); } } -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] internal sealed partial class PokedexExtensionPage : ListPage { private readonly List _kanto = @@ -448,14 +474,7 @@ internal sealed partial class PokedexExtensionPage : ListPage public override IListItem[] GetItems() => _kanto.AsEnumerable().Concat(_johto.AsEnumerable()).Concat(_hoenn.AsEnumerable()).Select(GetPokemonListItem).ToArray(); - private static ListItem GetPokemonListItem(Pokemon pokemon) - { - return new ListItem(new PokemonPage(pokemon)) - { - Subtitle = $"#{pokemon.Number}", - Tags = pokemon.Types.Select(t => new Tag() { Text = t, Background = GetColorForType(t) }).ToArray(), - }; - } + private static ListItem GetPokemonListItem(Pokemon pokemon) => new PokemonListItem(pokemon); // Dictionary mapping Pokémon types to their corresponding colors private static readonly Dictionary TypeColors = new() diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs new file mode 100644 index 0000000000..5dcbe501af --- /dev/null +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleContentPage.cs @@ -0,0 +1,338 @@ +// 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.CodeAnalysis; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +internal sealed partial class SampleContentPage : ContentPage +{ + private readonly SampleContentForm sampleForm = new(); + private readonly MarkdownContent sampleMarkdown = new() { Body = "# Sample page with mixed content \n This page has both markdown, and form content" }; + + public override IContent[] GetContent() => [sampleMarkdown, sampleForm]; + + public SampleContentPage() + { + Name = "Sample Content"; + Icon = new(string.Empty); + } +} + +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class SampleContentForm : FormContent +{ + public SampleContentForm() + { + TemplateJson = $$""" +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.6", + "body": [ + { + "type": "TextBlock", + "size": "medium", + "weight": "bolder", + "text": " ${ParticipantInfoForm.title}", + "horizontalAlignment": "center", + "wrap": true, + "style": "heading" + }, + { + "type": "Input.Text", + "label": "Name", + "style": "text", + "id": "SimpleVal", + "isRequired": true, + "errorMessage": "Name is required", + "placeholder": "Enter your name" + }, + { + "type": "Input.Text", + "label": "Homepage", + "style": "url", + "id": "UrlVal", + "placeholder": "Enter your homepage url" + }, + { + "type": "Input.Text", + "label": "Email", + "style": "email", + "id": "EmailVal", + "placeholder": "Enter your email" + }, + { + "type": "Input.Text", + "label": "Phone", + "style": "tel", + "id": "TelVal", + "placeholder": "Enter your phone number" + }, + { + "type": "Input.Text", + "label": "Comments", + "style": "text", + "isMultiline": true, + "id": "MultiLineVal", + "placeholder": "Enter any comments" + }, + { + "type": "Input.Number", + "label": "Quantity (Minimum -5, Maximum 5)", + "min": -5, + "max": 5, + "value": 1, + "id": "NumVal", + "errorMessage": "The quantity must be between -5 and 5" + }, + { + "type": "Input.Date", + "label": "Due Date", + "id": "DateVal", + "value": "2017-09-20" + }, + { + "type": "Input.Time", + "label": "Start time", + "id": "TimeVal", + "value": "16:59" + }, + { + "type": "TextBlock", + "size": "medium", + "weight": "bolder", + "text": "${Survey.title} ", + "horizontalAlignment": "center", + "wrap": true, + "style": "heading" + }, + { + "type": "Input.ChoiceSet", + "id": "CompactSelectVal", + "label": "${Survey.questions[0].question}", + "style": "compact", + "value": "1", + "choices": [ + { + "$data": "${Survey.questions[0].items}", + "title": "${choice}", + "value": "${value}" + } + ] + }, + { + "type": "Input.ChoiceSet", + "id": "SingleSelectVal", + "label": "${Survey.questions[1].question}", + "style": "expanded", + "value": "1", + "choices": [ + { + "$data": "${Survey.questions[1].items}", + "title": "${choice}", + "value": "${value}" + } + ] + }, + { + "type": "Input.ChoiceSet", + "id": "MultiSelectVal", + "label": "${Survey.questions[2].question}", + "isMultiSelect": true, + "value": "1,3", + "choices": [ + { + "$data": "${Survey.questions[2].items}", + "title": "${choice}", + "value": "${value}" + } + ] + }, + { + "type": "TextBlock", + "size": "medium", + "weight": "bolder", + "text": "Input.Toggle", + "horizontalAlignment": "center", + "wrap": true, + "style": "heading" + }, + { + "type": "Input.Toggle", + "label": "Please accept the terms and conditions:", + "title": "${Survey.questions[3].question}", + "valueOn": "true", + "valueOff": "false", + "id": "AcceptsTerms", + "isRequired": true, + "errorMessage": "Accepting the terms and conditions is required" + }, + { + "type": "Input.Toggle", + "label": "How do you feel about red cars?", + "title": "${Survey.questions[4].question}", + "valueOn": "RedCars", + "valueOff": "NotRedCars", + "id": "ColorPreference" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Submit", + "data": { + "id": "1234567890" + } + }, + { + "type": "Action.ShowCard", + "title": "Show Card", + "card": { + "type": "AdaptiveCard", + "body": [ + { + "type": "Input.Text", + "label": "Enter comment", + "style": "text", + "id": "CommentVal" + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "OK" + } + ] + } + } + ] +} +"""; + + DataJson = $$""" +{ + "ParticipantInfoForm": { + "title": "Input.Text elements" + }, + "Survey": { + "title": "Input ChoiceSet", + "questions": [ + { + "question": "What color do you want? (compact)", + "items": [ + { + "choice": "Red", + "value": "1" + }, + { + "choice": "Green", + "value": "2" + }, + { + "choice": "Blue", + "value": "3" + } + ] + }, + { + "question": "What color do you want? (expanded)", + "items": [ + { + "choice": "Red", + "value": "1" + }, + { + "choice": "Green", + "value": "2" + }, + { + "choice": "Blue", + "value": "3" + } + ] + }, + { + "question": "What color do you want? (multiselect)", + "items": [ + { + "choice": "Red", + "value": "1" + }, + { + "choice": "Green", + "value": "2" + }, + { + "choice": "Blue", + "value": "3" + } + ] + }, + { + "question": "I accept the terms and conditions (True/False)" + }, + { + "question": "Red cars are better than other cars" + } + ] + } +} +"""; + } + + public override CommandResult SubmitForm(string payload) + { + var formInput = JsonNode.Parse(payload)?.AsObject(); + if (formInput == null) + { + return CommandResult.GoHome(); + } + + // Application.Current.GetService().SaveSettingAsync("GlobalHotkey", formInput["hotkey"]?.ToString() ?? string.Empty); + return CommandResult.GoHome(); + } +} + +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class SampleTreeContentPage : ContentPage +{ + private readonly TreeContent myContentTree; + + public override IContent[] GetContent() => [myContentTree]; + + public SampleTreeContentPage() + { + Name = Title = "Sample Content"; + Icon = new("\uE81E"); + + myContentTree = new() + { + RootContent = new MarkdownContent() { Body = "# This page has nested content" }, + Children = [ + new TreeContent() + { + RootContent = new MarkdownContent() { Body = "Yo dog" }, + Children = [ + new TreeContent() + { + RootContent = new MarkdownContent() { Body = "I heard you like content" }, + Children = [ + new MarkdownContent() { Body = "So we put content in your content" }, + new FormContent() { TemplateJson = "{\"$schema\":\"http://adaptivecards.io/schemas/adaptive-card.json\",\"type\":\"AdaptiveCard\",\"version\":\"1.6\",\"body\":[{\"type\":\"TextBlock\",\"size\":\"medium\",\"weight\":\"bolder\",\"text\":\"Mix and match why don't you\",\"horizontalAlignment\":\"center\",\"wrap\":true,\"style\":\"heading\"},{\"type\":\"TextBlock\",\"text\":\"You can have forms here too\",\"horizontalAlignment\":\"Right\",\"wrap\":true}],\"actions\":[{\"type\":\"Action.Submit\",\"title\":\"It's a form, you get it\",\"data\":{\"id\":\"LoginVal\"}}]}" }, + new MarkdownContent() { Body = "Another markdown down here" }, + ], + }, + new MarkdownContent() { Body = "**slaps roof**" }, + new MarkdownContent() { Body = "This baby can fit so much content" }, + + ], + } + ], + }; + } +} diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs index ee69c851c7..b14eb4fbb9 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs @@ -11,7 +11,7 @@ internal sealed partial class SampleListPage : ListPage { public SampleListPage() { - Icon = new(string.Empty); + Icon = new("\uEA37"); Name = "Sample List Page"; } diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs index bc2ed2bbbf..4955b9deb3 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPageWithDetails.cs @@ -12,8 +12,8 @@ internal sealed partial class SampleListPageWithDetails : ListPage { public SampleListPageWithDetails() { - Icon = new(string.Empty); - Name = "Sample List Page with Details"; + Icon = new("\uE8A0"); + Name = Title = "Sample List Page with Details"; this.ShowDetails = true; } diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs index 93eceb4cac..e26b33bf64 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/SamplesListPage.cs @@ -10,59 +10,85 @@ namespace SamplePagesExtension; public partial class SamplesListPage : ListPage { private readonly IListItem[] _commands = [ - new ListItem(new SampleListPage()) - { - Title = "List Page Sample Command", - Subtitle = "Display a list of items", - }, - new ListItem(new SampleListPageWithDetails()) - { - Title = "List Page With Details", - 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", - Subtitle = "Changes the list of items in response to the typed query", - }, - new ListItem(new SampleMarkdownPage()) - { - Title = "Markdown Page Sample Command", - Subtitle = "Display a page of rendered markdown", - }, - new ListItem(new SampleMarkdownManyBodies()) - { - Title = "Markdown with multiple blocks", - Subtitle = "A page with multiple blocks of rendered markdown", - }, - new ListItem(new SampleMarkdownDetails()) - { - Title = "Markdown with details", - Subtitle = "A page with markdown and details", - }, + // List pages + new ListItem(new SampleListPage()) + { + Title = "List Page Sample Command", + Subtitle = "Display a list of items", + }, + new ListItem(new SampleListPageWithDetails()) + { + Title = "List Page With Details", + 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", + Subtitle = "Changes the list of items in response to the typed query", + }, - new ListItem(new SampleFormPage()) - { - Title = "Form Page Sample Command", - Subtitle = "Define inputs to retrieve input from the user", - }, - new ListItem(new SampleSettingsPage()) - { - Title = "Sample settings page", - Subtitle = "A demo of the settings helpers", - }, + // Content pages + new ListItem(new SampleContentPage()) + { + Title = "Sample content page", + Subtitle = "Display mixed forms, markdown, and other types of content", + }, + new ListItem(new SampleTreeContentPage()) + { + Title = "Sample nested content", + Subtitle = "Example of nesting a tree of content", + }, + new ListItem(new SampleCommentsPage()) + { + Title = "Sample of nested comments", + Subtitle = "Demo of using nested trees of content to create a comment thread-like experience", + Icon = new("\uE90A"), // Comment + }, - new ListItem(new EvilSamplesPage()) - { - Title = "Evil samples", - Subtitle = "Samples designed to break the palette in many different evil ways", - } + // DEPRECATED: Markdown pages + new ListItem(new SampleMarkdownPage()) + { + Title = "Markdown Page Sample Command", + Subtitle = "Display a page of rendered markdown", + }, + new ListItem(new SampleMarkdownManyBodies()) + { + Title = "Markdown with multiple blocks", + Subtitle = "A page with multiple blocks of rendered markdown", + }, + new ListItem(new SampleMarkdownDetails()) + { + Title = "Markdown with details", + Subtitle = "A page with markdown and details", + }, + + // DEPRECATED: Form pages + new ListItem(new SampleFormPage()) + { + Title = "Form Page Sample Command", + Subtitle = "Define inputs to retrieve input from the user", + }, + + // Settings helpers + new ListItem(new SampleSettingsPage()) + { + Title = "Sample settings page", + Subtitle = "A demo of the settings helpers", + }, + + // Evil edge cases + // Anything weird that might break the palette - put that in here. + new ListItem(new EvilSamplesPage()) + { + Title = "Evil samples", + Subtitle = "Samples designed to break the palette in many different evil ways", + } ]; public SamplesListPage() diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs index 9a5927e49f..0293e3ee2f 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/SelfImmolateCommand.cs @@ -13,6 +13,6 @@ public partial class SelfImmolateCommand : InvokableCommand public override ICommandResult Invoke() { Process.GetCurrentProcess().Kill(); - return base.Invoke(); + return CommandResult.GoHome(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs index 3d7e50329f..42ead59ecd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ActionBarViewModel.cs @@ -78,5 +78,6 @@ public partial class ActionBarViewModel : ObservableObject, // InvokeItemCommand is what this will be in Xaml due to source generator [RelayCommand] - private void InvokeItem(CommandContextItemViewModel item) => WeakReferenceMessenger.Default.Send(new(item.Command)); + private void InvokeItem(CommandContextItemViewModel item) => + WeakReferenceMessenger.Default.Send(new(item.Command, item.Model)); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs index c233b59097..39fa7b01c9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandContextItemViewModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class CommandContextItemViewModel(ICommandContextItem contextItem, IPageContext context) : CommandItemViewModel(new(contextItem), context) { - private readonly ExtensionObject _contextItemModel = new(contextItem); + public ExtensionObject Model { get; } = new(contextItem); public bool IsCritical { get; private set; } @@ -19,7 +19,7 @@ public partial class CommandContextItemViewModel(ICommandContextItem contextItem { base.InitializeProperties(); - var contextItem = _contextItemModel.Unsafe; + var contextItem = Model.Unsafe; if (contextItem == null) { return; // throw? diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 0459b600e3..ed5240916e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -179,12 +179,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel break; case nameof(Icon): var listIcon = model.Icon; - var iconInfo = listIcon != null ? listIcon : Command.Unsafe!.Icon; + var iconInfo = listIcon ?? Command.Unsafe!.Icon; Icon = new(iconInfo); Icon.InitializeProperties(); break; - // TODO! MoreCommands array, which needs to also raise HasMoreCommands + // TODO GH #360 - make MoreCommands observable + // which needs to also raise HasMoreCommands } UpdateProperty(propertyName); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs new file mode 100644 index 0000000000..60a5df2f5d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs @@ -0,0 +1,145 @@ +// 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.Text.Json; +using AdaptiveCards.ObjectModel.WinUI3; +using AdaptiveCards.Templating; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Models; +using Windows.Data.Json; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContentFormViewModel(IFormContent _form, IPageContext context) : + ContentViewModel(context) +{ + private readonly ExtensionObject _formModel = new(_form); + + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] + public string TemplateJson { get; protected set; } = "{}"; + + public string StateJson { get; protected set; } = "{}"; + + public string DataJson { get; protected set; } = "{}"; + + public AdaptiveCardParseResult? Card { get; private set; } + + public override void InitializeProperties() + { + var model = _formModel.Unsafe; + if (model == null) + { + return; + } + + try + { + TemplateJson = model.TemplateJson; + StateJson = model.StateJson; + DataJson = model.DataJson; + + AdaptiveCardTemplate template = new(TemplateJson); + var cardJson = template.Expand(DataJson); + Card = AdaptiveCard.FromJsonString(cardJson); + } + catch (Exception e) + { + // If we fail to parse the card JSON, then display _our own card_ + // with the exception + AdaptiveCardTemplate template = new(ErrorCardJson); + + // todo: we could probably stick Card.Errors in there too + var dataJson = $$""" +{ + "error_message": {{JsonSerializer.Serialize(e.Message)}}, + "error_stack": {{JsonSerializer.Serialize(e.StackTrace)}}, + "inner_exception": {{JsonSerializer.Serialize(e.InnerException?.Message)}}, + "template_json": {{JsonSerializer.Serialize(TemplateJson)}}, + "data_json": {{JsonSerializer.Serialize(DataJson)}} +} +"""; + var cardJson = template.Expand(dataJson); + Card = AdaptiveCard.FromJsonString(cardJson); + } + + UpdateProperty(nameof(Card)); + } + + public void HandleSubmit(IAdaptiveActionElement action, JsonObject inputs) + { + if (action is AdaptiveOpenUrlAction openUrlAction) + { + WeakReferenceMessenger.Default.Send(new(openUrlAction.Url)); + return; + } + + if (action is AdaptiveSubmitAction or AdaptiveExecuteAction) + { + // Get the data and inputs + // var data = submitAction.DataJson.Stringify(); + var inputString = inputs.Stringify(); + + // _ = data; + _ = inputString; + + try + { + var model = _formModel.Unsafe!; + if (model != null) + { + var result = model.SubmitForm(inputString); + WeakReferenceMessenger.Default.Send(new(new(result))); + } + } + catch (Exception ex) + { + PageContext.ShowException(ex); + } + } + } + + private static readonly string ErrorCardJson = """ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "Error parsing form from extension", + "wrap": true, + "style": "heading", + "size": "ExtraLarge", + "weight": "Bolder", + "color": "Attention" + }, + { + "type": "TextBlock", + "wrap": true, + "text": "${error_message}", + "color": "Attention" + }, + { + "type": "TextBlock", + "text": "${error_stack}", + "fontType": "Monospace" + }, + { + "type": "TextBlock", + "wrap": true, + "text": "Inner exception:" + }, + { + "type": "TextBlock", + "wrap": true, + "text": "${inner_exception}", + "color": "Attention" + } + ] +} +"""; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs new file mode 100644 index 0000000000..e7e52cfec9 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentMarkdownViewModel.cs @@ -0,0 +1,68 @@ +// 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 AdaptiveCards.ObjectModel.WinUI3; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContentMarkdownViewModel(IMarkdownContent _markdown, IPageContext context) : + ContentViewModel(context) +{ + public ExtensionObject Model { get; } = new(_markdown); + + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] + public string Body { get; protected set; } = string.Empty; + + public AdaptiveCardParseResult? Card { get; private set; } + + public override void InitializeProperties() + { + var model = Model.Unsafe; + if (model == null) + { + return; + } + + Body = model.Body; + UpdateProperty(nameof(Body)); + + model.PropChanged += Model_PropChanged; + } + + private void Model_PropChanged(object sender, PropChangedEventArgs args) + { + try + { + var propName = args.PropertyName; + FetchProperty(propName); + } + catch (Exception ex) + { + PageContext.ShowException(ex); + } + } + + protected void FetchProperty(string propertyName) + { + var model = Model.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(Body): + Body = model.Body; + break; + } + + UpdateProperty(propertyName); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs new file mode 100644 index 0000000000..071adef1cd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentPageViewModel.cs @@ -0,0 +1,166 @@ +// 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 System.Diagnostics.CodeAnalysis; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContentPageViewModel : PageViewModel +{ + private readonly ExtensionObject _model; + + [ObservableProperty] + public partial ObservableCollection Content { get; set; } = []; + + public List Commands { get; private set; } = []; + + public bool HasCommands => Commands.Count > 0; + + public DetailsViewModel? Details { get; private set; } + + [MemberNotNullWhen(true, nameof(Details))] + public bool HasDetails => Details != null; + + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] + public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, CommandPaletteHost host) + : base(model, scheduler, host) + { + _model = new(model); + } + + // 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, ItemsChangedEventArgs args) => FetchContent(); + + //// Run on background thread, from InitializeAsync or Model_ItemsChanged + private void FetchContent() + { + List newContent = []; + try + { + var newItems = _model.Unsafe!.GetContent(); + + foreach (var item in newItems) + { + var viewModel = ViewModelFromContent(item, PageContext); + if (viewModel != null) + { + viewModel.InitializeProperties(); + newContent.Add(viewModel); + } + } + } + catch (Exception ex) + { + ShowException(ex, _model?.Unsafe?.Name); + throw; + } + + // Now, back to a UI thread to update the observable collection + Task.Factory.StartNew( + () => + { + ListHelpers.InPlaceUpdateList(Content, newContent); + }, + CancellationToken.None, + TaskCreationOptions.None, + PageContext.Scheduler); + } + + public static ContentViewModel? ViewModelFromContent(IContent content, IPageContext context) + { + ContentViewModel? viewModel = content switch + { + IFormContent form => new ContentFormViewModel(form, context), + IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context), + ITreeContent tree => new ContentTreeViewModel(tree, context), + _ => null, + }; + return viewModel; + } + + public override void InitializeProperties() + { + base.InitializeProperties(); + + var model = _model.Unsafe; + if (model == null) + { + return; // throw? + } + + Commands = model.Commands + .Where(contextItem => contextItem is ICommandContextItem) + .Select(contextItem => (contextItem as ICommandContextItem)!) + .Select(contextItem => new CommandContextItemViewModel(contextItem, PageContext)) + .ToList(); + + var extensionDetails = model.Details; + if (extensionDetails != null) + { + Details = new(extensionDetails, PageContext); + Details.InitializeProperties(); + } + + UpdateDetails(); + + FetchContent(); + model.ItemsChanged += Model_ItemsChanged; + } + + protected override void FetchProperty(string propertyName) + { + base.FetchProperty(propertyName); + + var model = this._model.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + // case nameof(Commands): + // TODO GH #360 - make MoreCommands observable + // this.ShowDetails = model.ShowDetails; + // break; + case nameof(Details): + var extensionDetails = model.Details; + Details = extensionDetails != null ? new(extensionDetails, PageContext) : null; + UpdateDetails(); + break; + } + + UpdateProperty(propertyName); + } + + private void UpdateDetails() + { + UpdateProperty(nameof(Details)); + UpdateProperty(nameof(HasDetails)); + + Task.Factory.StartNew( + () => + { + if (HasDetails) + { + WeakReferenceMessenger.Default.Send(new(Details)); + } + else + { + WeakReferenceMessenger.Default.Send(); + } + }, + CancellationToken.None, + TaskCreationOptions.None, + PageContext.Scheduler); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs new file mode 100644 index 0000000000..51f812f0be --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentTreeViewModel.cs @@ -0,0 +1,130 @@ +// 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 Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContentTreeViewModel(ITreeContent _tree, IPageContext context) : + ContentViewModel(context) +{ + public ExtensionObject Model { get; } = new(_tree); + + // Remember - "observable" properties from the model (via PropChanged) + // cannot be marked [ObservableProperty] + public ContentViewModel? RootContent { get; protected set; } + + public ObservableCollection Children { get; } = []; + + public bool HasChildren => Children.Count > 0; + + public ObservableCollection StupidGames => [RootContent]; + + public override void InitializeProperties() + { + var model = Model.Unsafe; + if (model == null) + { + return; + } + + var root = model.RootContent; + if (root != null) + { + RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext); + RootContent?.InitializeProperties(); + UpdateProperty(nameof(RootContent)); + UpdateProperty(nameof(StupidGames)); + } + + FetchContent(); + model.PropChanged += Model_PropChanged; + model.ItemsChanged += Model_ItemsChanged; + } + + // 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, ItemsChangedEventArgs args) => FetchContent(); + + private void Model_PropChanged(object sender, PropChangedEventArgs args) + { + try + { + var propName = args.PropertyName; + FetchProperty(propName); + } + catch (Exception ex) + { + PageContext.ShowException(ex); + } + } + + protected void FetchProperty(string propertyName) + { + var model = Model.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(RootContent): + var root = model.RootContent; + if (root != null) + { + RootContent = ContentPageViewModel.ViewModelFromContent(root, PageContext); + } + else + { + root = null; + } + + UpdateProperty(nameof(StupidGames)); + + break; + } + + UpdateProperty(propertyName); + } + + //// Run on background thread, from InitializeAsync or Model_ItemsChanged + private void FetchContent() + { + List newContent = []; + try + { + var newItems = Model.Unsafe!.GetChildren(); + + foreach (var item in newItems) + { + var viewModel = ContentPageViewModel.ViewModelFromContent(item, PageContext); + if (viewModel != null) + { + viewModel.InitializeProperties(); + newContent.Add(viewModel); + } + } + } + catch (Exception ex) + { + PageContext.ShowException(ex); + throw; + } + + // Now, back to a UI thread to update the observable collection + Task.Factory.StartNew( + () => + { + ListHelpers.InPlaceUpdateList(Children, newContent); + }, + CancellationToken.None, + TaskCreationOptions.None, + PageContext.Scheduler); + + UpdateProperty(nameof(HasChildren)); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs new file mode 100644 index 0000000000..224019a1eb --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentViewModel.cs @@ -0,0 +1,10 @@ +// 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; + +public abstract partial class ContentViewModel(IPageContext context) : + ExtensionObjectViewModel(context) +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs index 0f0674d72d..22bedf7df9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListItemViewModel.cs @@ -12,7 +12,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class ListItemViewModel(IListItem model, IPageContext context) : CommandItemViewModel(new(model), context) { - private readonly ExtensionObject _listItemModel = new(model); + public ExtensionObject Model { get; } = new(model); // Remember - "observable" properties from the model (via PropChanged) // cannot be marked [ObservableProperty] @@ -33,7 +33,7 @@ public partial class ListItemViewModel(IListItem model, IPageContext context) { base.InitializeProperties(); - var li = _listItemModel.Unsafe; + var li = Model.Unsafe; if (li == null) { return; // throw? @@ -67,7 +67,7 @@ public partial class ListItemViewModel(IListItem model, IPageContext context) { base.FetchProperty(propertyName); - var model = this._listItemModel.Unsafe; + var model = this.Model.Unsafe; if (model == null) { return; // throw? @@ -108,7 +108,7 @@ public partial class ListItemViewModel(IListItem model, IPageContext context) public override string ToString() => $"{Name} ListItemViewModel"; - public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm._listItemModel.Equals(this._listItemModel); + public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model); - public override int GetHashCode() => _listItemModel.GetHashCode(); + public override int GetHashCode() => Model.GetHashCode(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index 29a66cf40e..bbd9c4f8b3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -37,7 +37,7 @@ public partial class ListViewModel : PageViewModel public string ModelPlaceholderText { get => string.IsNullOrEmpty(field) ? "Type here to search..." : field; private set; } = string.Empty; - public override string PlaceholderText { get => ModelPlaceholderText; } + public override string PlaceholderText => ModelPlaceholderText; public string SearchText { get; private set; } = string.Empty; @@ -49,6 +49,7 @@ public partial class ListViewModel : PageViewModel _model = new(model); } + // 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, ItemsChangedEventArgs args) => FetchItems(); protected override void OnFilterUpdated(string filter) @@ -194,14 +195,14 @@ public partial class ListViewModel : PageViewModel // InvokeItemCommand is what this will be in Xaml due to source generator [RelayCommand] private void InvokeItem(ListItemViewModel item) => - WeakReferenceMessenger.Default.Send(new(item.Command)); + WeakReferenceMessenger.Default.Send(new(item.Command, item.Model)); [RelayCommand] private void InvokeSecondaryCommand(ListItemViewModel item) { if (item.SecondaryCommand != null) { - WeakReferenceMessenger.Default.Send(new(item.SecondaryCommand.Command)); + WeakReferenceMessenger.Default.Send(new(item.SecondaryCommand.Command, item.Model)); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs index 3d134805a2..0bf8098867 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/MarkdownPageViewModel.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; +using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.Extensions; using Microsoft.CmdPal.Extensions.Helpers; @@ -38,7 +39,7 @@ public partial class MarkdownPageViewModel : PageViewModel //// Run on background thread, from InitializeAsync or Model_ItemsChanged private void FetchContent() { - List newBodies = new(); + List newBodies = []; try { var newItems = _model.Unsafe!.Bodies(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs index 08ee9f1301..3e99460dca 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/PerformCommandMessage.cs @@ -10,6 +10,27 @@ namespace Microsoft.CmdPal.UI.ViewModels.Messages; /// /// Used to do a command - navigate to a page or invoke it /// -public record PerformCommandMessage(ExtensionObject Command) +public record PerformCommandMessage { + public ExtensionObject Command { get; } + + public object? Context { get; } + + public PerformCommandMessage(ExtensionObject command) + { + Command = command; + Context = null; + } + + public PerformCommandMessage(ExtensionObject command, ExtensionObject context) + { + Command = command; + Context = context.Unsafe; + } + + public PerformCommandMessage(ExtensionObject command, ExtensionObject context) + { + Command = command; + Context = context.Unsafe; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs index d71dc2c795..bb9719e71a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TagViewModel.cs @@ -33,7 +33,6 @@ public partial class TagViewModel(ITag _tag, IPageContext context) : ExtensionOb return; } - Command = new(model.Command); Text = model.Text; Foreground = model.Foreground; Background = model.Background; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ContentTemplateSelector.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ContentTemplateSelector.xaml.cs new file mode 100644 index 0000000000..b6575bf0f0 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ContentTemplateSelector.xaml.cs @@ -0,0 +1,37 @@ +// 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.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +public partial class ContentTemplateSelector : DataTemplateSelector +{ + // Define the (currently empty) data templates to return + // These will be "filled-in" in the XAML code. + public DataTemplate? FormTemplate { get; set; } + + public DataTemplate? MarkdownTemplate { get; set; } + + public DataTemplate? TreeTemplate { get; set; } + + protected override DataTemplate? SelectTemplateCore(object item) + { + if (item is ContentViewModel element) + { + var data = element; + return data switch + { + ContentFormViewModel => FormTemplate, + ContentMarkdownViewModel => MarkdownTemplate, + ContentTreeViewModel => TreeTemplate, + _ => null, + }; + } + + return null; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml new file mode 100644 index 0000000000..f29e940850 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml @@ -0,0 +1,25 @@ + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs new file mode 100644 index 0000000000..89a218bffd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ContentFormControl.xaml.cs @@ -0,0 +1,88 @@ +// 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 AdaptiveCards.ObjectModel.WinUI3; +using AdaptiveCards.Rendering.WinUI3; +using Microsoft.CmdPal.UI.ViewModels; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI.Controls; + +public sealed partial class ContentFormControl : UserControl +{ + private static readonly AdaptiveCardRenderer _renderer; + private ContentFormViewModel? _viewModel; + + // LOAD-BEARING: if you don't hang onto a reference to the RenderedAdaptiveCard + // then the GC might clean it up sometime, even while the card is in the UI + // tree. If this gets GC'd, then it'll revoke our Action handler, and the + // form will do seemingly nothing. + private RenderedAdaptiveCard? _renderedCard; + + public ContentFormViewModel? ViewModel { get => _viewModel; set => AttachViewModel(value); } + + static ContentFormControl() + { + _renderer = new AdaptiveCardRenderer(); + } + + public ContentFormControl() + { + this.InitializeComponent(); + var lightTheme = ActualTheme == Microsoft.UI.Xaml.ElementTheme.Light; + _renderer.HostConfig = lightTheme ? AdaptiveCardsConfig.Light : AdaptiveCardsConfig.Dark; + + // TODO in the future, we should handle ActualThemeChanged and replace + // our rendered card with one for that theme. But today is not that day + } + + private void AttachViewModel(ContentFormViewModel? vm) + { + if (_viewModel != null) + { + _viewModel.PropertyChanged -= ViewModel_PropertyChanged; + } + + _viewModel = vm; + + if (_viewModel != null) + { + _viewModel.PropertyChanged += ViewModel_PropertyChanged; + + var c = _viewModel.Card; + if (c != null) + { + DisplayCard(c); + } + } + } + + private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (ViewModel == null) + { + return; + } + + if (e.PropertyName == nameof(ViewModel.Card)) + { + var c = ViewModel.Card; + if (c != null) + { + DisplayCard(c); + } + } + } + + private void DisplayCard(AdaptiveCardParseResult result) + { + _renderedCard = _renderer.RenderAdaptiveCard(result.AdaptiveCard); + ContentGrid.Children.Clear(); + ContentGrid.Children.Add(_renderedCard.FrameworkElement); + _renderedCard.Action += Rendered_Action; + } + + private void Rendered_Action(RenderedAdaptiveCard sender, AdaptiveActionEventArgs args) => + ViewModel?.HandleSubmit(args.Action, args.Inputs.AsJson()); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml new file mode 100644 index 0000000000..ccac8065e2 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs new file mode 100644 index 0000000000..6e746bbd4e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ContentPage.xaml.cs @@ -0,0 +1,46 @@ +// 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.UI.ViewModels; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.CmdPal.UI; + +/// +/// An empty page that can be used on its own or navigated to within a Frame. +/// +public sealed partial class ContentPage : Page +{ + private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); + + public ContentPageViewModel? ViewModel + { + get => (ContentPageViewModel?)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + // Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc... + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register(nameof(ViewModel), typeof(ContentPageViewModel), typeof(FormsPage), new PropertyMetadata(null)); + + public ContentPage() + { + this.InitializeComponent(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + if (e.Parameter is ContentPageViewModel vm) + { + ViewModel = vm; + } + + base.OnNavigatedTo(e); + } + + protected override void OnNavigatingFrom(NavigatingCancelEventArgs e) => base.OnNavigatingFrom(e); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/MarkdownPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/MarkdownPage.xaml.cs index f871fc5ecd..eb828008f5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/MarkdownPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/MarkdownPage.xaml.cs @@ -2,10 +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 Microsoft.CmdPal.UI.ViewModels; -using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 4b193ad4fd..358542b4c3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -132,6 +132,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, }, IFormPage formsPage => new FormsPageViewModel(formsPage, _mainTaskScheduler, host), IMarkdownPage markdownPage => new MarkdownPageViewModel(markdownPage, _mainTaskScheduler, host), + IContentPage contentPage => new ContentPageViewModel(contentPage, _mainTaskScheduler, host), _ => throw new NotSupportedException(), }; @@ -145,6 +146,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, IListPage => typeof(ListPage), IFormPage => typeof(FormsPage), IMarkdownPage => typeof(MarkdownPage), + IContentPage => typeof(ContentPage), _ => throw new NotSupportedException(), }, pageViewModel, @@ -165,8 +167,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, } else if (command is IInvokableCommand invokable) { - // TODO Handle results - var result = invokable.Invoke(); + var result = invokable.Invoke(message.Context); HandleCommandResult(result); } } diff --git a/src/modules/cmdpal/WindowsCommandPalette/Views/MainPage.xaml.cs b/src/modules/cmdpal/WindowsCommandPalette/Views/MainPage.xaml.cs index 407e509e03..86fd938bd5 100644 --- a/src/modules/cmdpal/WindowsCommandPalette/Views/MainPage.xaml.cs +++ b/src/modules/cmdpal/WindowsCommandPalette/Views/MainPage.xaml.cs @@ -48,10 +48,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page RootFrame.Navigate(typeof(ListPage), rootListVm, new DrillInNavigationTransitionInfo()); } - private void ExtensionService_OnExtensionsChanged(object? sender, EventArgs e) - { - _ = LoadAllCommands(); - } + private void ExtensionService_OnExtensionsChanged(object? sender, EventArgs e) => _ = LoadAllCommands(); private void HackyBadClearFilter() { @@ -116,7 +113,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page TryAllowForeground(command); if (command is IInvokableCommand invokable) { - HandleResult(invokable.Invoke()); + HandleResult(invokable.Invoke(null)); return; } else if (command is IListPage listPage) @@ -236,10 +233,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page } } - private void RequestGoHomeHandler(object sender, object args) - { - DoGoHome(); - } + private void RequestGoHomeHandler(object sender, object args) => DoGoHome(); private void DoGoHome() { @@ -254,10 +248,7 @@ public sealed partial class MainPage : Microsoft.UI.Xaml.Controls.Page } } - private void AppendLog(string message) - { - _log += message + "\n"; - } + private void AppendLog(string message) => _log += message + "\n"; private async Task LoadExtensions() { diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/generate-interface.ps1 b/src/modules/cmdpal/doc/initial-sdk-spec/generate-interface.ps1 index 5b7a0f4756..752dbec3c5 100644 --- a/src/modules/cmdpal/doc/initial-sdk-spec/generate-interface.ps1 +++ b/src/modules/cmdpal/doc/initial-sdk-spec/generate-interface.ps1 @@ -13,6 +13,10 @@ foreach ($item in $json.children) { # Each line that starts with `runtimeclass` or `interface` should be prefixed with the contract attribute $code = $code -replace "(?m)^(runtimeclass|interface) ", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]`n`$1 " + # if there are two [contract] attributes, remove the second one + $code = $code -replace "(?m)^\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, ([0-9]+)\)\]\n\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, 1\)\]", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, `$1)]" + + # all the lines that start with `(whitespace)async (T)` should be translated to `IAsyncOperation` $code = $code -replace "(?m)^(\s*)async\s+(void)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncAction `$3(" $code = $code -replace "(?m)^(\s*)async\s+([A-Za-z0-9_<>]+)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncOperation<`$2> `$3(" @@ -33,6 +37,10 @@ foreach ($item in $json.children) { # Each line that starts with `runtimeclass` or `interface` should be prefixed with the contract attribute $code = $code -replace "(?m)^(runtimeclass|interface) ", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)]`n`$1 " + # if there are two [contract] attributes, remove the second one + $code = $code -replace "(?m)^\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, ([0-9]+)\)\]\n\[contract\(Microsoft.CmdPal.Extensions.ExtensionsContract, 1\)\]", "[contract(Microsoft.CmdPal.Extensions.ExtensionsContract, `$1)]" + + # all the lines that start with `(whitespace)async (T)` should be translated to `IAsyncOperation` $code = $code -replace "(?m)^(\s*)async\s+(void)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncAction `$3(" $code = $code -replace "(?m)^(\s*)async\s+([A-Za-z0-9_<>]+)\s+([A-Za-z0-9_]+)\s*\(", "`$1Windows.Foundation.IAsyncOperation<`$2> `$3(" @@ -72,7 +80,7 @@ namespace Microsoft.CmdPal.Extensions String Icon { get; }; Windows.Storage.Streams.IRandomAccessStreamReference Data { get; }; }; - + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] runtimeclass IconInfo { IconInfo(String iconString); diff --git a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md index 07033f4ca6..afe83cd3eb 100644 --- a/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md +++ b/src/modules/cmdpal/doc/initial-sdk-spec/initial-sdk-spec.md @@ -1,7 +1,7 @@ --- author: Mike Griese created on: 2024-07-19 -last updated: 2025-01-08 +last updated: 2025-02-03 issue id: n/a --- @@ -57,9 +57,12 @@ functionality. - [Filtering the list](#filtering-the-list) - [Markdown Pages](#markdown-pages) - [Form Pages](#form-pages) + - [Content Pages](#content-pages) + - [Markdown Content](#markdown-content) + - [Form Content](#form-content) - [Other types](#other-types) - [`ContextItem`s](#contextitems) - - [Icons - `IconInfo` and `IconData`](#icons---iconinfo-and-icondatatype) + - [Icons - `IconInfo` and `IconData`](#icons---iconinfo-and-icondata) - [`OptionalColor`](#optionalcolor) - [`Details`](#details) - [`INotifyPropChanged`](#inotifypropchanged) @@ -80,6 +83,8 @@ functionality. - [URI activation](#uri-activation) - [Custom "empty list" messages](#custom-empty-list-messages) - [Footnotes](#footnotes) + - [Generating the `.idl`](#generating-the-idl) + - [Adding APIs](#adding-apis) ## Background @@ -256,7 +261,7 @@ commands won't change over time. These extensions can be cached to save resources. Command providers can opt out of this behavior by setting `Frozen=false` in -their extension. We'll call these extensions "**fresh, never frozen**". +their extension. We'll call these extensions "**fresh, never frozen**". As some examples: * The "Hacker News" extension, only has a single top-level action. Once we load @@ -265,7 +270,7 @@ As some examples: * Similarly for something like the GitHub extension - it's got multiple top-level commands (My issues, Issue search, Repo search, etc), but these top-level commands never change. This is a **frozen** extension. -* The "Quick Links" extension has a dynamic list of top-level actions. +* The "Quick Links" extension has a dynamic list of top-level actions. This is a **fresh** extension.[^3] * The "Media Controls" extension only has a single top-level action, but it needs to be running to be able to update it's title and icon. So we can't just @@ -311,18 +316,18 @@ The structure of the data DevPal caches will look something like the following: ``` In this data you can see: -* We cache some basic info about each extension we've seen. This includes - * the Package Family Name (a unique identifier per-app package), - * the COM CLSID for that extension, +* We cache some basic info about each extension we've seen. This includes + * the Package Family Name (a unique identifier per-app package), + * the COM CLSID for that extension, * the display name for that extension, - * and if that extension is frozen or not. + * and if that extension is frozen or not. * We also cache the list of top-level commands for that extension. We'll store the basic amount of info we need to recreate that command in the top-level list. On a cold launch, DevPal will do the following: -1. SLOW: First we start up WASDK and XAML. Unavoidable cost. +1. SLOW: First we start up WASDK and XAML. Unavoidable cost. 2. FAST: We load builtin extensions. These are just extensions in DLLs, so there's nothing to it. 3. FAST: We load our cache of extensions from disk, and note which are frozen vs fresh @@ -342,10 +347,10 @@ On a cold launch, DevPal will do the following: 5. SLOW: We open the package catalog for more commands * Extensions that we've seen before in our cache: * If it's fresh, we'll start it, and fill in commands from `TopLevelCommands` into the palette - * If it's frozen, we'll leave it be. We've already got stubs for it. - * Extensions we've never seen before: + * If it's frozen, we'll leave it be. We've already got stubs for it. + * Extensions we've never seen before: * Start it up. - * Check if it's fresh or frozen. + * Check if it's fresh or frozen. * Call `TopLevelCommands`, and put all of them in the list * Create a extension cache entry for that app. * If the provider is frozen: we can actually release the @@ -355,9 +360,9 @@ On a cold launch, DevPal will do the following: 6. We start a package catalog change watcher to be notified by the OS for changes to the list of installed extensions -After 1, we can display the UI. It won't have any commands though, so maybe we should wait. +After 1, we can display the UI. It won't have any commands though, so maybe we should wait. After 2, we'd have some commands, but nothing from extensions -After 4, the palette is ready to be used, with all the frozen extension commands. This is probably good enough for most use cases. +After 4, the palette is ready to be used, with all the frozen extension commands. This is probably good enough for most use cases. Most of the time, when the user "launches" devPal, we won't run through this whole process. The slowest part of startup is standing up WASDK and WinUI. After @@ -383,7 +388,7 @@ command), we need to quickly load that app and get the command for it. 3. Check if the extension is already in the warm extension cache. If it is, we recently reheated a command from this provider. We can skip step 4 and go straight to step 5 -4. Use the CLSID from the cache to `CoCreateInstance` this extension, and get its `ICommandProvider`. +4. Use the CLSID from the cache to `CoCreateInstance` this extension, and get its `ICommandProvider`. * If that fails: display an error message. 5. Try to load the command from the provider. This is done in two steps: 1. If the cached command had an `id`, try to look up the command with @@ -393,7 +398,7 @@ command), we need to quickly load that app and get the command for it. null): all `TopLevelItems` on that `CommandProvider`. * Search through all the returned commands with the same `id` or `icon/title/subtitle/name`, and return that one. -6. If we found the command from the provider, navigate to it or invoke it. +6. If we found the command from the provider, navigate to it or invoke it. ##### Microwaved commands @@ -409,7 +414,7 @@ keep warm at a given time. We'll probably also want to offer an option like the memory usage as much. > [WARNING!] -> +> > If your command provider returns a `IFallbackCommandItem`s from > `FallbackCommands`, and that provider is marked `frozen`, DevPal will always > treat your provider as "fresh". Otherwise, devpal wouldn't be able to call @@ -428,7 +433,7 @@ that limit of recent commands, we'll release our reference to the COM object for that extension, and re-mark commands from it as "stubs". Upon the release of that reference, the extension is free to clean itself up. For extensions that use the helpers library, they can override `CommandProvider.Dispose` to do -cleanup in there. +cleanup in there. ## Installing extensions @@ -490,7 +495,7 @@ anything that a 1p built-in can do. The SDK for DevPal is split into two namespaces: * `Microsoft.Windows.Run` - This namespace contains the interfaces that developers will implement to create extensions for DevPal. -* `Microsoft.Windows.Run.Extensions` - This namespace contains helper classes +* `Microsoft.CmdPal.Extensions.Helpers` - This namespace contains helper classes that developers can use to make creating extensions easier. The first is highly abstract, and gives developers total control over the @@ -540,9 +545,11 @@ enum CommandResultKind { Dismiss, // Reset the palette to the main page and dismiss GoHome, // Go back to the main page, but keep it open GoBack, // Go back one level - Hide, // Keep this page open, but hide the palette. + Hide, // Keep this page open, but hide the palette. KeepOpen, // Do nothing. GoToPage, // Go to another page. GoToPageArgs will tell you where. + ShowToast, // Display a transient message to the user + Confirm, // Display a confirmation dialog }; enum NavigationMode { @@ -561,13 +568,24 @@ interface IGoToPageArgs requires ICommandResultArgs{ String PageId { get; }; NavigationMode NavigationMode { get; }; } +interface IToastArgs requires ICommandResultArgs{ + String Message { get; }; + ICommandResult Result { get; }; +} +interface IConfirmationArgs requires ICommandResultArgs{ + String Title { get; }; + String Description { get; }; + ICommand PrimaryCommand { get; }; + Boolean IsPrimaryCommandCritical { get; }; +} // This is a "leaf" of the UI. This is something that can be "done" by the user. // * A ListPage // * the MoreCommands flyout of for a ListItem or a MarkdownPage interface IInvokableCommand requires ICommand { - ICommandResult Invoke(); + ICommandResult Invoke(Object sender); } + ``` If a developer wants to add a simple action to DevPal, they can create a @@ -577,7 +595,7 @@ method will be called when the user selects the action in DevPal. As a simple example[^1]: ```cs -class HackerNewsAction : Microsoft.Windows.Run.Extensions.InvokableCommand { +class HackerNewsAction : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand { public class HackerNewsAction() { Name = "Hacker News"; @@ -603,6 +621,31 @@ The `Id` property is optional. This can be set but the extension author to support more efficient command lookup in [`ICommandProvider.GetCommand()`, below](#getcommand). +When `Invoke` is called, the host app will pass in a `sender` object that +represents the context of where that command was invoked from. This can be +different types depending on where the command is being used: + +* `TopLevelCommands` (and fallbacks) + * Sender is the `ICommandItem` for the top-level command that was invoked +* `IListPage.GetItems` + * Sender is the `IListItem` for the list item selected for that command +* `ICommandItem.MoreCommands` (context menus) + * Sender is the `IListItem` which the command was attached to for a list page, or + * the `ICommandItem` of the top-level command (if this is a context item on a top level command) +* `IContentPage.Commands` + * Sender is the `IContentPage` itself + +The helpers library also exposes a `Invoke()` method on `InvokableCommand` which +takes no parameters, as a convenience for developers who don't need the `sender` +object. + +Using the `sender` parameter can be useful for big lists of items where the +actionable information for each item is practically the same. Consider a big +list of links. An extension developer can implement this as a single +`IInvokableCommand` that opens a URL based on the `sender` object passed in. +Then each list item would store the URL to open and the title of the link. This +creates less overhead for the extension and host to communicate. + #### Results Commands can return a `CommandResult` to indicate what DevPal should do after @@ -638,9 +681,29 @@ Use cases for each `CommandResultKind`: stay in its current state. * `GoToPage` - Navigate to a different page in DevPal. The `GoToPageArgs` will specify which page to navigate to. - * [TODO!]: Do we actually need this, now that all the commands can be pages? - * Does this satisfy "I want to pop the stack, but then push something else - onto the stack"? Versus the default which is just "add this to the stack"? + * `Push`: The new page gets added to the current navigation stack. Going back + from the requested page will take you to the current page. + * `GoBack`: Go back one level, then navigate to the page. Going back from the + requested page will take you to the page before the current page. + * `GoHome`: Clear the back stack, then navigate to the page. Going back from + the requested page will take you to the home page (the L0). +* `ShowToast` - Display a transient desktop-level message to the user. This is + especially useful for displaying confirmation that an action took place, when + the palette will be closed. Consider the `CopyTextCommand` in the helpers - + this command will show a toast with the text "Copied to clipboard", then + dismiss the palette. + * Once the message is displayed, the palette will then react to the `Result`. + In the helpers library, the `ToastArgs`'s default `Result` value is + `Dismiss`. + * Only one toast can be displayed at a time. If a new toast is requested + before the previous one is dismissed, the new toast will replace the old + one. This includes if the `Result` of one `IToastArgs` is another + `IToastArgs`. +* `Confirm`: Display a confirmation dialog to the user. This is useful for + actions that are destructive or irreversible. The `ConfirmationArgs` will + specify the title, and description for the dialog. The primary button of the + dialog will activate the `Command`. If `IsPrimaryCommandCritical` is `true`, + the primary button will be red, indicating that it is a destructive action. ### Pages @@ -654,7 +717,7 @@ information that the host application will then use to render the page. interface IPage requires ICommand { String Title { get; }; Boolean IsLoading { get; }; - + OptionalColor AccentColor { get; }; } ``` @@ -663,9 +726,8 @@ When a user selects an action that implements `IPage`, DevPal will navigate to that page, pushing it onto the UI stack. Pages can be one of several types, each detailed below: -* [List](#List) -* [Markdown](#Markdown) -* [Form](#Form) +* [List](#List-Pages) +* [Content](#Content-Pages) If a page returns a null or empty `Title`, DevPal will display the `Name` of the `ICommand` instead. @@ -706,7 +768,7 @@ Lists can be either "static" or "dynamic": results - it's the extension's responsibility to filter them. * Ex: The GitHub extension may want to allow the user to type `is:issue is:open`, then return a list of open issues, without string matching on - the text. + the text. ```csharp @@ -719,7 +781,7 @@ interface ICommandItem requires INotifyPropChanged { IconInfo Icon{ get; }; String Title{ get; }; String Subtitle{ get; }; -} +} interface ICommandContextItem requires ICommandItem, IContextItem { Boolean IsCritical { get; }; // READ: "make this red" @@ -741,7 +803,7 @@ interface IGridProperties { } interface IListPage requires IPage, INotifyItemsChanged { - // DevPal will be responsible for filtering the list of items, unless the + // DevPal will be responsible for filtering the list of items, unless the // class implements IDynamicListPage String SearchText { get; }; String PlaceholderText { get; }; @@ -751,7 +813,7 @@ interface IListPage requires IPage, INotifyItemsChanged { Boolean HasMoreItems { get; }; ICommandItem EmptyContent { get; }; - IListItem[] GetItems(); + IListItem[] GetItems(); void LoadMore(); } @@ -767,7 +829,7 @@ Lists are comprised of a collection of `IListItems`. ![Another mockup of the elements of a list item](./list-elements-mock-002.png) -> NOTE: The above diagram is from before Nov 2024. It doesn't properly include the relationship between `ICommandItems` and list items. +> NOTE: The above diagram is from before Nov 2024. It doesn't properly include the relationship between `ICommandItems` and list items. > A more up-to-date explainer of the elements of the UI can be found in > ["Rendering of ICommandItems in Lists and Menus"](#rendering-of-icommanditems-in-lists-and-menus) @@ -787,13 +849,13 @@ the commands for the currently selected item. The elements of a ListPage (`IListItem`s) and the context menu (`ICommandContextItem`) both share the same base type. Basically, they're both a list of things which have: -* A `ICommand` to invoke or navigate to. -* a `Title` which might replace their `Command`'s `Name`, -* an `Icon` which might replace their `Command`'s `Icon`, +* A `ICommand` to invoke or navigate to. +* a `Title` which might replace their `Command`'s `Name`, +* an `Icon` which might replace their `Command`'s `Icon`, * A `Subtitle`, which is visible on the list, and a _tooltip_ for a context menu * They might also have `MoreCommands`: - * For a `IListItem`, this is the context menu. - * For a ContextItem in the context menu, this creates a sub-context menu. + * For a `IListItem`, this is the context menu. + * For a ContextItem in the context menu, this creates a sub-context menu. For more details on the structure of the `MoreCommands` property, see the [`ContextItem`s](#contextitems) section below. @@ -917,21 +979,21 @@ class NewsPost { string Poster; int Points; } -class LinkAction(NewsPost post) : Microsoft.Windows.Run.Extensions.InvokableCommand { +class LinkAction(NewsPost post) : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand { public string Name => "Open link"; - public ActionResult Invoke() { + public CommandResult Invoke() { Process.Start(new ProcessStartInfo(post.Url) { UseShellExecute = true }); - return ActionResult.KeepOpen; + return CommandResult.KeepOpen; } } -class CommentAction(NewsPost post) : Microsoft.Windows.Run.Extensions.InvokableCommand { +class CommentAction(NewsPost post) : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand { public string Name => "Open comments"; - public ActionResult Invoke() { + public CommandResult Invoke() { Process.Start(new ProcessStartInfo(post.CommentsUrl) { UseShellExecute = true }); - return ActionResult.KeepOpen; + return CommandResult.KeepOpen; } } -class NewsListItem(NewsPost post) : Microsoft.Windows.Run.Extensions.ListItem { +class NewsListItem(NewsPost post) : Microsoft.CmdPal.Extensions.Helpers.ListItem { public string Title => post.Title; public string Subtitle => post.Poster; public IContextItem[] Commands => [ @@ -940,7 +1002,7 @@ class NewsListItem(NewsPost post) : Microsoft.Windows.Run.Extensions.ListItem { ]; public ITag[] Tags => [ new Tag(){ Text=post.Points } ]; } -class HackerNewsPage: Microsoft.Windows.Run.Extensions.ListPage { +class HackerNewsPage: Microsoft.CmdPal.Extensions.Helpers.ListPage { public bool Loading => true; IListItem[] GetItems() { List items = /* do some RSS feed stuff */; @@ -987,16 +1049,16 @@ Here's a breakdown of how a dynamic list responds to the CmdPal. In this example, we'll use a hypothetical GitHub issue search extension, which allows the user to type a query and get a list of issues back. -1. CmdPal loads the `ListPage` from the extension. +1. CmdPal loads the `ListPage` from the extension. 2. It is a `IDynamicListPage`, so the command palette knows not to do any host-side filtering. -3. CmdPal reads the `SearchText` from the ListPage +3. CmdPal reads the `SearchText` from the ListPage - it returns `is:issue is:open` as initial text -4. CmdPal reads the `HasMoreItems` from the ListPage +4. CmdPal reads the `HasMoreItems` from the ListPage - it returns `true` -5. CmdPal calls `GetItems()` - - the extension returns the first 25 items that match the query. -6. User scrolls the page to the bottom +5. CmdPal calls `GetItems()` + - the extension returns the first 25 items that match the query. +6. User scrolls the page to the bottom - CmdPal calls `GetMore` on the ListPage, to let it know it should start fetching more results 7. The extension raises a `ItemsChanged(40)`, to indicate that it now has 40 items @@ -1089,7 +1151,7 @@ applied to top-level `IListItem`s: * We won't display any context menu commands for these entries * We won't display the Details for these entries (nor the context action to show details) -* Icons which are a `IRandomAccessStream` will not work as expected. +* Icons which are a `IRandomAccessStream` will not work as expected. * If you create a top-level `IListItem` that implements `IFallbackHandler`, DevPal will treat your `ICommandProvider` as fresh, never frozen, regardless of the value of `Frozen` you set. @@ -1100,12 +1162,6 @@ on nested pages will all work exactly as expected. --> #### Markdown Pages -This is a page that displays a block of markdown text. This is useful for -showing a lot of information in a small space. Markdown provides a rich set of -simple formatting options. - -![](./markdown-mock.png) - ```csharp interface IMarkdownPage requires IPage { String[] Bodies(); // TODO! should this be an IBody, so we can make it observable? @@ -1114,75 +1170,8 @@ interface IMarkdownPage requires IPage { } ``` -A markdown page may also have a `Details` property, which will be displayed in -the same way as the details for a list item. This is useful for showing -additional information about the page, like a description, a preview of a file, -or a link to more information. - -Similar to the `List` page, the `Commands` property is a list of commands that the -user can take on the page. These are the commands that will be shown in the "More -actions" flyout. Unlike the `List` page, the `Commands` property is not -associated with any specific item on the page, rather, these commands are global -to the page itself. - -An example markdown page for an issue on GitHub: - -```cs -class GitHubIssue { - string Title; - string Url; - string Body; - string Author; - string[] Tags; - string[] AssignedTo; -} -class OpenLinkAction(GitHubIssue issue) : Microsoft.Windows.Run.Extensions.InvokableCommand { - public string Name => "Open"; - public ActionResult Invoke() { - Process.Start(new ProcessStartInfo(issue.Url) { UseShellExecute = true }); - return ActionResult.KeepOpen; - } -} -class GithubIssuePage(GithubIssue issue): Microsoft.Windows.Run.Extensions.MarkdownPage { - public bool Loading => true; - public string Body() { - issue.Body = /* fetch the body from the API */; - this.IsLoading = false; - return issue.Body; - } - public IContextItem[] Commands => [ new CommandContextItem(new OpenLinkAction(issue)) ]; - public IDetails Details() { - return new Details(){ - Title = "", - Body = "", - Metadata = [ - new Microsoft.Windows.Run.Extensions.DetailsTags(){ - Key = "Author", - Tags = new(){ new Tag(){ Text = issue.Author } } - }, - new Microsoft.Windows.Run.Extensions.DetailsTags(){ - Key = "Assigned To", - Tags = issue.AssignedTo.Select((user) => new Tag(){ Text = user }).ToArray() - }, - new Microsoft.Windows.Run.Extensions.DetailsTags(){ - Key = "Tags", - Tags = issue.Tags.Select((tag) => new Tag(){ Text = tag }).ToArray() - } - ] - }; - } -} -``` - #### Form Pages -A form page allows the user to input data to the extension. This is useful for -actions that might require additional information from the user. For example: -imagine a "Send Teams message" action. This action might require the user to -input the message they want to send, and give the user a dropdown to pick the -chat to send the message to. - -![](./form-page-prototype.png) ```csharp @@ -1197,7 +1186,120 @@ interface IFormPage requires IPage { } ``` -Form pages are powered by [Adaptive Cards](https://adaptivecards.io/). This + +#### Content Pages + +Content pages are used for extensions that want to display richer content than +just a list of commands to the user. These pages are useful for displaying +things like documents and forms. You can mix and match different types of +content on a single page, and even nest content within other content. + +```csharp +[uuid("b64def0f-8911-4afa-8f8f-042bd778d088")] +interface IContent requires INotifyPropChanged { +} + +interface IFormContent requires IContent { + String TemplateJson { get; }; + String DataJson { get; }; + String StateJson { get; }; + ICommandResult SubmitForm(String payload); +} + +interface IMarkdownContent requires IContent { + String Body { get; }; +} + +interface ITreeContent requires IContent, INotifyItemsChanged { + IContent RootContent { get; }; + IContent[] GetChildren(); +} + +interface IContentPage requires IPage, INotifyItemsChanged { + IContent[] GetContent(); + IDetails Details { get; }; + IContextItem[] Commands { get; }; +} +``` + +Content pages may also have a `Details` property, which will be displayed in +the same way as the details for a list item. This is useful for showing +additional information about the page, like a description, a preview of a file, +or a link to more information. + +Similar to the `List` page, the `Commands` property is a list of commands that the +user can take on the page. These are the commands that will be shown in the "More +actions" flyout. Unlike the `List` page, the `Commands` property is not +associated with any specific item on the page, rather, these commands are global +to the page itself. + +##### Markdown Content + +This is a block of content that displays text formatted with Markdown. This is +useful for showing a lot of information in a small space. Markdown provides a +rich set of simple formatting options. + +![](./markdown-mock.png) + + +An example markdown page for an issue on GitHub: + +```cs +class GitHubIssue { + string Title; + string Url; + string Body; + string Author; + string[] Tags; + string[] AssignedTo; +} +class GithubIssuePage: Microsoft.CmdPal.Extensions.Helpers.ContentPage { + private readonly MarkdownContent issueBody; + public GithubIssuePage(GithubIssue issue) + { + Commands = [ new CommandContextItem(new Microsoft.CmdPal.Extensions.Helpers.OpenUrlCommand(issue.Url)) ]; + Details = new Details(){ + Title = "", + Body = "", + Metadata = [ + new Microsoft.CmdPal.Extensions.Helpers.DetailsTags(){ + Key = "Author", + Tags = [new Tag(issue.Author)] + }, + new Microsoft.CmdPal.Extensions.Helpers.DetailsTags(){ + Key = "Assigned To", + Tags = issue.AssignedTo.Select((user) => new Tag(user)).ToArray() + }, + new Microsoft.CmdPal.Extensions.Helpers.DetailsTags(){ + Key = "Tags", + Tags = issue.Tags.Select((tag) => new Tag(tag)).ToArray() + } + ] + }; + + issueBody = new MarkdownContent(issue.Body); + } + + public override IContent[] GetContent() => [issueBody]; +} +``` + +> [!NOTE] +> A real GitHub extension would likely load the issue body asynchronously. In +> that case, the page could start a background thread to fetch the content, then +> raise the ItemsChanged to signal the host to retrieve the new `IContent`. + +##### Form Content + +Forms allow the user to input data to the extension. This is useful for +actions that might require additional information from the user. For example: +imagine a "Send Teams message" action. This action might require the user to +input the message they want to send, and give the user a dropdown to pick the +chat to send the message to. + +![](./form-page-prototype.png) + +Form content is powered by [Adaptive Cards](https://adaptivecards.io/). This allows extension developers a rich set of controls to use in their forms. Each page can have as many forms as it needs. These forms will be displayed to the user as separate "cards", in the order they are returned by the extension. @@ -1209,7 +1311,6 @@ When the user submits the form, the `SubmitForm` method will be called with the JSON payload of the form. The extension is responsible for parsing this payload and acting on it. -[TODO!discussion]: Do we want to stick the `Actions` on this page type too? Or does that not make sense? ### Other types @@ -1288,7 +1389,7 @@ struct OptionalColor We also define `OptionalColor` as a helper struct here. Yes, this is also just an `IReference`. However, `IReference` has some weird ownership semantics that just make it a pain for something as simple as "maybe this color doesn't -have a value set". +have a value set". #### `Details` @@ -1317,7 +1418,6 @@ interface ITag { OptionalColor Foreground { get; }; OptionalColor Background { get; }; String ToolTip { get; }; - ICommand Command { get; }; }; [uuid("6a6dd345-37a3-4a1e-914d-4f658a4d583d")] @@ -1339,6 +1439,9 @@ interface IDetailsLink requires IDetailsData { Windows.Foundation.Uri Link { get; }; String Text { get; }; } +interface IDetailsCommand requires IDetailsData { + ICommand Command { get; }; +} [uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")] interface IDetailsSeparator requires IDetailsData {} ``` @@ -1378,7 +1481,7 @@ This is the interface that an extension must implement to provide commands to De ```csharp interface ICommandSettings { - IFormPage SettingsPage { get; }; + IContentPage SettingsPage { get; }; }; interface IFallbackHandler { @@ -1409,7 +1512,7 @@ interface ICommandProvider requires Windows.Foundation.IClosable, INotifyItemsCh `TopLevelCommands` is the method that DevPal will call to get the list of actions that should be shown when the user opens DevPal. These are the commands that will allow the user to interact with the rest of your extension. They can be simple -actions, or they can be pages that the user can navigate to. +actions, or they can be pages that the user can navigate to. `TopLevelCommands` returns a list of `ICommandItem`s. These are basically just a simpler form of `IListItem`, which can be displayed even as a stub (as described @@ -1421,14 +1524,14 @@ something like an extension that might require the user to login before accessing certain pages within the extension. Command Providers which are `Frozen=true` can also use this event to change their list of cached commands, since the only time an extension can raise this event is when it's already -running. +running. `Id` is only necessary to set if your extension implements multiple providers in the same package identity. This is an uncommon scenario which most developers shouldn't need to worry about. If you do set `Id`, it should be a stable string across package versions. DevPal will use this Id for tracking settings for each provider within a package. Changing this string will result in the user's -settings for your extension being lost. +settings for your extension being lost. #### Fallback commands @@ -1462,7 +1565,7 @@ As an example, here's how a developer might implement a fallback action that changes its name to be mOcKiNgCaSe. ```cs -public class SpongebotPage : Microsoft.Windows.Run.Extensions.MarkdownPage, IFallbackHandler +public class SpongebotPage : Microsoft.CmdPal.Extensions.Helpers.MarkdownPage, IFallbackHandler { // Name, Icon, IPropertyChanged: all those are defined in the MarkdownPage base class public SpongebotPage() @@ -1496,7 +1599,7 @@ internal sealed class SpongebotCommandsProvider : CommandProvider { public ICommandItem[] TopLevelCommands() => []; public IFallbackCommandItem[] FallbackCommands() - { + { var spongebotPage = new SpongebotPage(); var listItem = new FallbackCommandItem(spongebotPage); // ^ The FallbackCommandItem ctor will automatically set its FallbackHandler to the @@ -1506,7 +1609,7 @@ internal sealed class SpongebotCommandsProvider : CommandProvider } ``` -`Microsoft.Windows.Run.Extensions.FallbackCommandItem` in the SDK helpers will automatically set +`Microsoft.CmdPal.Extensions.Helpers.FallbackCommandItem` in the SDK helpers will automatically set the `FallbackHandler` property on the `IFallbackCommandItem` to the `Command` it's initialized with, if that command implements `IFallbackHandler`. This allows the action to directly update itself in response to the query. You may also specify @@ -1521,7 +1624,7 @@ If an extension's own list page wants to implement a similar fallback mechanism - it's free to use `IDynamicListPage` to listen for changes to the query and have its own ListItem it updates manually. -> [!IMPORTANT] +> [!IMPORTANT] > If your extension has top-level `FallbackCommandItem`s, then > DevPal will treat your `ICommandProvider` as fresh, never frozen, regardless of the value of `Frozen` you set. @@ -1536,7 +1639,7 @@ that command. For command providers that have multiple top-level commands, this can be a helpful short-circuit. The extension won't need to construct instances of all the `IListItem`s for all its top-level commands. Instead, the extension can just -instantiate the requested one. +instantiate the requested one. ##### Settings @@ -1585,7 +1688,7 @@ We'll provide default implementations for the following interfaces: This will allow developers to quickly create extensions without having to worry about implementing every part of the interface. You can see that reference implementation in -`extensionsdk\Microsoft.Windows.Run.Extensions.Lib\DefaultClasses.cs`. +`extensionsdk\Microsoft.CmdPal.Extensions.Helpers.Lib\DefaultClasses.cs`. In addition to the default implementations we provide for the interfaces above, we should provide a set of helper classes that make it easier for developers to @@ -1594,7 +1697,7 @@ write extensions. For example, we should have something like: ```cs -class OpenUrlAction(string targetUrl, ActionResult result) : Microsoft.Windows.Run.Extensions.InvokableCommand { +class OpenUrlAction(string targetUrl, CommandResult result) : Microsoft.CmdPal.Extensions.Helpers.InvokableCommand { public OpenUrlAction() { Name = "Open"; @@ -1612,7 +1715,7 @@ that no longer do we need to add additional classes for the actions. We just use the helper: ```cs -class NewsListItem : Microsoft.Windows.Run.Extensions.ListItem { +class NewsListItem : Microsoft.CmdPal.Extensions.Helpers.ListItem { private NewsPost _post; public NewsListItem(NewsPost post) { @@ -1629,7 +1732,7 @@ class NewsListItem : Microsoft.Windows.Run.Extensions.ListItem { ]; public ITag[] Tags => [ new Tag(){ Text=post.Poster, new Tag(){ Text=post.Points } } ]; } -class HackerNewsPage: Microsoft.Windows.Run.Extensions.ListPage { +class HackerNewsPage: Microsoft.CmdPal.Extensions.Helpers.ListPage { public HackerNewsPage() { Loading = true; @@ -1678,7 +1781,7 @@ class MyAppSettings { public Helpers.Settings Settings => _settings; public MyAppSettings() { - // Define the structure of your settings here. + // Define the structure of your settings here. var onOffSetting = new Helpers.ToggleSetting("onOff", "Enable feature", "This feature will do something cool", true); var textSetting = new Helpers.TextSetting("whatever", "Text setting", "This is a text setting", "Default text"); _settings.Add(onOffSetting); @@ -1695,11 +1798,11 @@ class MyAppSettings { /* You can save the settings to the file here */ var mySettingsFilePath = /* whatever */; string mySettingsJson = mySettings.Settings.GetState(); - // Or you could raise a event to indicate to the rest of your app that settings have changed. + // Or you could raise a event to indicate to the rest of your app that settings have changed. } } -class MySettingsPage : Microsoft.Windows.Run.Extensions.FormPage +class MySettingsPage : Microsoft.CmdPal.Extensions.Helpers.FormPage { private readonly MyAppSettings mySettings; public MySettingsPage(MyAppSettings s) { @@ -1707,26 +1810,26 @@ class MySettingsPage : Microsoft.Windows.Run.Extensions.FormPage mySettings.Settings.SettingsChanged += SettingsChanged; } public override IForm[] Forms() { - // If you haven't already: + // If you haven't already: mySettings.Settings.LoadSavedData(); return mySettings.Settings.ToForms(); } - + private void SettingsChanged(object sender, Settings args) { /* Do something with the new settings here */ var onOff = _settings.GetSetting("onOff"); ExtensionHost.LogMessage(new LogMessage() { Message = $"MySettingsPage: Changed the value of onOff to {onOff}" }); - // Possibly even: + // Possibly even: mySettings.SaveSettings(); } } // elsewhere in your app: -MyAppSettings instance = /* Up to you how you want to pass this around. - Singleton, dependency injection, whatever. */ +MyAppSettings instance = /* Up to you how you want to pass this around. + Singleton, dependency injection, whatever. */ var onOff = instance.Settings.Get("onOff"); ``` @@ -1738,7 +1841,7 @@ var onOff = instance.Settings.Get("onOff"); Extensions will want to be able to communicate feedback to the user, based on -what's going on inside the extension. +what's going on inside the extension. Consider a `winget` extension that allows the user to search for and install packages. When the user starts an install, the extension should be able to show @@ -1831,20 +1934,20 @@ When displaying a command context menu item: * The text is `ICommandItem.Title ?? ICommandItem.Command.Name` * The tooltip is `ICommandItem.Subtitle` -When displaying a `IListItem`'s default `Command` as a context item, we'll make a new +When displaying a `IListItem`'s default `Command` as a context item, we'll make a new ```cs -ICommandContextItem(){ +ICommandContextItem(){ Command = ICommandItem.Command, MoreCommands = null, - Icon = Command.Icon, // use icon from command, not list item + Icon = Command.Icon, // use icon from command, not list item Title = Command.Name, // Use command's name, not list item Subtitle = IListItem.Title, // Use the title of the list item as the tooltip on the context menu IsCritical = false, } ``` -If a `ICommandItem` in a context menu has `MoreCommands`, then activating it will open a submenu with those items. -If a `ICommandItem` in a context menu has `MoreCommands` AND a non-null `Command`, then activating it will open a submenu with the `Command` first (following the same rules above for building a context item from a default `Command`), followed by the items in `MoreCommands`. +If a `ICommandItem` in a context menu has `MoreCommands`, then activating it will open a submenu with those items. +If a `ICommandItem` in a context menu has `MoreCommands` AND a non-null `Command`, then activating it will open a submenu with the `Command` first (following the same rules above for building a context item from a default `Command`), followed by the items in `MoreCommands`. When displaying a page: * The title will be `IPage.Title ?? ICommand.Name` @@ -1875,7 +1978,7 @@ classDiagram IInvokableCommand --|> ICommand class IInvokableCommand { - ICommandResult Invoke() + ICommandResult Invoke(object context) } class IForm { @@ -2028,7 +2131,7 @@ I had originally started to spec this out as: ```cs interface IInvokableCommandWithParameters requires ICommand { ActionParameters Parameters { get; }; - ActionResult InvokeWithArgs(ActionArguments args); + CommandResult InvokeWithArgs(ActionArguments args); } ``` @@ -2042,7 +2145,7 @@ follow - these are not part of the current SDK spec. > [!NOTE] > -> A thought: what if a action returns a `ActionResult.Entity`, then that takes +> A thought: what if a action returns a `CommandResult.Entity`, then that takes > devpal back home, but leaves the entity in the query box. This would allow for > a Quicksilver-like "thing, do" flow. That command would prepopulate the > parameters. So we would then filter top-level commands based on things that can @@ -2050,7 +2153,7 @@ follow - these are not part of the current SDK spec. > > For example: The user uses the "Search for file" list page. They find the file > they're looking for. That file's ListItem has a context item "With -> {filename}..." that then returns a `ActionResult.Entity` with the file entity. +> {filename}..." that then returns a `CommandResult.Entity` with the file entity. > The user is taken back to the main page, and a file picker badge (with that > filename) is at the top of the search box. In that state, the only commands > now shown are ones that can accept a File entity. This could be things like @@ -2101,6 +2204,8 @@ Is that just a `Details` object? A markdown body? ## Footnotes +### Generating the `.idl` + The `.idl` for this SDK can be generated directly from this file. To do so, run the following command: ```ps1 @@ -2112,9 +2217,16 @@ The `.idl` for this SDK can be generated directly from this file. To do so, run Or, to generate straight to the place I'm consuming it from: ```ps1 -.\doc\initial-sdk-spec\generate-interface.ps1 > .\extensionsdk\Microsoft.CmdPal.Extensions\Microsoft.Windows.Run.Extensions.idl +.\doc\initial-sdk-spec\generate-interface.ps1 > .\extensionsdk\Microsoft.CmdPal.Extensions\Microsoft.CmdPal.Extensions.Helpers.idl ``` +### Adding APIs + +Almost all of the SDK defined here is in terms of interfaces. Unfortunately, +this prevents us from being able to use `[contract]` attributes to add to the +interfaces. We'll instead need to rely on the tried-and-true method of adding a +`IFoo2` when we want to add methods to `IFoo`. + [^1]: In this example, as in other places, I've referenced a `Microsoft.DevPal.Extensions.InvokableCommand` class, as the base for that action. Our SDK will include partial class implementations for interfaces like @@ -2141,4 +2253,4 @@ Or, to generate straight to the place I'm consuming it from: palette re-cache the results of `TopLevelItems`. [Dev Home Extension]: https://learn.microsoft.com/en-us/windows/dev-home/extensions -[`ISupportIncrementalLoading`]: https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.data.isupportincrementalloading \ No newline at end of file +[`ISupportIncrementalLoading`]: https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.data.isupportincrementalloading diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandResult.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandResult.cs index f49cf71379..e4ab9a0359 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandResult.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/CommandResult.cs @@ -6,20 +6,15 @@ namespace Microsoft.CmdPal.Extensions.Helpers; public class CommandResult : ICommandResult { - // TODO: is Args needed? - private ICommandResultArgs? _args; - private CommandResultKind _kind = CommandResultKind.Dismiss; + public ICommandResultArgs? Args { get; private set; } - // TODO: is Args needed? - public ICommandResultArgs? Args => _args; - - public CommandResultKind Kind => _kind; + public CommandResultKind Kind { get; private set; } = CommandResultKind.Dismiss; public static CommandResult Dismiss() { return new CommandResult() { - _kind = CommandResultKind.Dismiss, + Kind = CommandResultKind.Dismiss, }; } @@ -27,8 +22,8 @@ public class CommandResult : ICommandResult { return new CommandResult() { - _kind = CommandResultKind.GoHome, - _args = null, + Kind = CommandResultKind.GoHome, + Args = null, }; } @@ -36,8 +31,8 @@ public class CommandResult : ICommandResult { return new CommandResult() { - _kind = CommandResultKind.GoBack, - _args = null, + Kind = CommandResultKind.GoBack, + Args = null, }; } @@ -45,8 +40,8 @@ public class CommandResult : ICommandResult { return new CommandResult() { - _kind = CommandResultKind.Hide, - _args = null, + Kind = CommandResultKind.Hide, + Args = null, }; } @@ -54,8 +49,44 @@ public class CommandResult : ICommandResult { return new CommandResult() { - _kind = CommandResultKind.KeepOpen, - _args = null, + Kind = CommandResultKind.KeepOpen, + Args = null, + }; + } + + public static CommandResult GoToPage(GoToPageArgs args) + { + return new CommandResult() + { + Kind = CommandResultKind.GoToPage, + Args = args, + }; + } + + public static CommandResult ShowToast(ToastArgs args) + { + return new CommandResult() + { + Kind = CommandResultKind.ShowToast, + Args = args, + }; + } + + public static CommandResult ShowToast(string message) + { + return new CommandResult() + { + Kind = CommandResultKind.ShowToast, + Args = new ToastArgs() { Message = message }, + }; + } + + public static CommandResult Confirm(ConfirmationArgs args) + { + return new CommandResult() + { + Kind = CommandResultKind.Confirm, + Args = args, }; } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ConfirmationArgs.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ConfirmationArgs.cs new file mode 100644 index 0000000000..df89c0895d --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ConfirmationArgs.cs @@ -0,0 +1,16 @@ +// 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.Extensions.Helpers; + +public partial class ConfirmationArgs : IConfirmationArgs +{ + public string? Title { get; set; } + + public string? Description { get; set; } + + public ICommand? PrimaryCommand { get; set; } + + public bool IsPrimaryCommandCritical { get; set; } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ContentPage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ContentPage.cs new file mode 100644 index 0000000000..501173c882 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ContentPage.cs @@ -0,0 +1,38 @@ +// 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 Windows.Foundation; + +namespace Microsoft.CmdPal.Extensions.Helpers; + +public abstract partial class ContentPage : Page, IContentPage +{ + public event TypedEventHandler? ItemsChanged; + + public IDetails? Details + { + get => field; + set + { + field = value; + OnPropertyChanged(nameof(Details)); + } + } + + public IContextItem[] Commands { get; set; } = []; + + public abstract IContent[] GetContent(); + + protected void RaiseItemsChanged(int totalItems) + { + try + { + // TODO #181 - This is the same thing that BaseObservable has to deal with. + ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + } + catch + { + } + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsCommand.cs new file mode 100644 index 0000000000..c02d275c10 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsCommand.cs @@ -0,0 +1,10 @@ +// 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.Extensions.Helpers; + +public partial class DetailsCommand : IDetailsCommand +{ + public ICommand? Command { get; set; } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsLink.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsLink.cs index 1b4c42c781..601f9b8b6e 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsLink.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/DetailsLink.cs @@ -9,4 +9,23 @@ public partial class DetailsLink : IDetailsLink public Uri? Link { get; set; } public string Text { get; set; } = string.Empty; + + public DetailsLink() + { + } + + public DetailsLink(string url) + : this(url, url) + { + } + + public DetailsLink(string url, string text) + { + if (Uri.TryCreate(url, default(UriCreationOptions), out var newUri)) + { + Link = newUri; + } + + Text = text; + } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Form.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Form.cs index 986e3d2ee7..cee117a2c0 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Form.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Form.cs @@ -4,7 +4,7 @@ namespace Microsoft.CmdPal.Extensions.Helpers; -public abstract class Form : IForm +public abstract partial class Form : IForm { public virtual string Data { get; set; } = string.Empty; diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/FormContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/FormContent.cs new file mode 100644 index 0000000000..43b1a7b4c5 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/FormContent.cs @@ -0,0 +1,46 @@ +// 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.Extensions.Helpers; + +public partial class FormContent : BaseObservable, IFormContent +{ + public virtual string DataJson + { + get; + set + { + field = value; + OnPropertyChanged(nameof(DataJson)); + } + } + += string.Empty; + + public virtual string StateJson + { + get; + set + { + field = value; + OnPropertyChanged(nameof(StateJson)); + } + } + += string.Empty; + + public virtual string TemplateJson + { + get; + set + { + field = value; + OnPropertyChanged(nameof(TemplateJson)); + } + } + += string.Empty; + + public virtual ICommandResult SubmitForm(string payload) => CommandResult.KeepOpen(); +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/GoToPageArgs.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/GoToPageArgs.cs index 3a38625c80..470a2b9aa7 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/GoToPageArgs.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/GoToPageArgs.cs @@ -4,7 +4,7 @@ namespace Microsoft.CmdPal.Extensions.Helpers; -public class GoToPageArgs : IGoToPageArgs +public partial class GoToPageArgs : IGoToPageArgs { public required string PageId { get; set; } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/InvokableCommand.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/InvokableCommand.cs index 8418ed6108..656f5ef477 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/InvokableCommand.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/InvokableCommand.cs @@ -4,7 +4,9 @@ namespace Microsoft.CmdPal.Extensions.Helpers; -public class InvokableCommand : Command, IInvokableCommand +public abstract class InvokableCommand : Command, IInvokableCommand { - public virtual ICommandResult Invoke() => throw new NotImplementedException(); + public virtual ICommandResult Invoke() => CommandResult.KeepOpen(); + + public virtual ICommandResult Invoke(object? sender) => Invoke(); } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/MarkdownContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/MarkdownContent.cs new file mode 100644 index 0000000000..445c2c19d7 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/MarkdownContent.cs @@ -0,0 +1,20 @@ +// 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.Extensions.Helpers; + +public partial class MarkdownContent : BaseObservable, IMarkdownContent +{ + public virtual string Body + { + get; + set + { + field = value; + OnPropertyChanged(nameof(Body)); + } + } + += string.Empty; +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Microsoft.CmdPal.Extensions.Helpers.csproj b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Microsoft.CmdPal.Extensions.Helpers.csproj index 9cf05121aa..609e27e893 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Microsoft.CmdPal.Extensions.Helpers.csproj +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Microsoft.CmdPal.Extensions.Helpers.csproj @@ -7,6 +7,7 @@ false enable enable + preview Microsoft.CmdPal.Extensions.Helpers.pri AnyCPU diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/StatusMessage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/StatusMessage.cs index 329ae4ddb6..9415cd41fb 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/StatusMessage.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/StatusMessage.cs @@ -6,38 +6,36 @@ namespace Microsoft.CmdPal.Extensions.Helpers; public partial class StatusMessage : BaseObservable, IStatusMessage { - private MessageState _messageState = MessageState.Info; - - private string _message = string.Empty; - - private IProgressState? _progressState; - public string Message { - get => _message; + get; set { - _message = value; + field = value; OnPropertyChanged(nameof(Message)); } } += string.Empty; + public MessageState State { - get => _messageState; + get; set { - _messageState = value; + field = value; OnPropertyChanged(nameof(State)); } } += MessageState.Info; + public IProgressState? Progress { - get => _progressState; + get; set { - _progressState = value; + field = value; OnPropertyChanged(nameof(Progress)); } } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Tag.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Tag.cs index 733ecb7d62..c549652717 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Tag.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/Tag.cs @@ -8,10 +8,7 @@ public class Tag : BaseObservable, ITag { private OptionalColor _foreground; private OptionalColor _background; - private IconInfo _icon = new(string.Empty); private string _text = string.Empty; - private string _toolTip = string.Empty; - private ICommand? _command; public OptionalColor Foreground { @@ -35,14 +32,16 @@ public class Tag : BaseObservable, ITag public IconInfo Icon { - get => _icon; + get; set { - _icon = value; + field = value; OnPropertyChanged(nameof(Icon)); } } += new(string.Empty); + public string Text { get => _text; @@ -55,23 +54,15 @@ public class Tag : BaseObservable, ITag public string ToolTip { - get => _toolTip; + get; set { - _toolTip = value; + field = value; OnPropertyChanged(nameof(ToolTip)); } } - public ICommand? Command - { - get => _command; - set - { - _command = value; - OnPropertyChanged(nameof(Command)); - } - } += string.Empty; public Tag() { diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastArgs.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastArgs.cs new file mode 100644 index 0000000000..82745fecb3 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastArgs.cs @@ -0,0 +1,12 @@ +// 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.Extensions.Helpers; + +public partial class ToastArgs : IToastArgs +{ + public string? Message { get; set; } + + public ICommandResult? Result { get; set; } = CommandResult.Dismiss(); +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastStatusMessage.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastStatusMessage.cs new file mode 100644 index 0000000000..80b25356c9 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ToastStatusMessage.cs @@ -0,0 +1,47 @@ +// 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.Extensions.Helpers; + +public partial class ToastStatusMessage +{ + private readonly Lock _showLock = new(); + private bool _shown; + + public StatusMessage Message { get; init; } + + public int Duration { get; init; } = 2500; + + public ToastStatusMessage(StatusMessage message) + { + Message = message; + } + + public ToastStatusMessage(string text) + { + Message = new StatusMessage() { Message = text }; + } + + public void Show() + { + lock (_showLock) + { + if (!_shown) + { + ExtensionHost.ShowStatus(Message); + _ = Task.Run(() => + { + Thread.Sleep(Duration); + + lock (_showLock) + { + _shown = false; + ExtensionHost.HideStatus(Message); + } + }); + _shown = true; + } + } + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/TreeContent.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/TreeContent.cs new file mode 100644 index 0000000000..15a637b742 --- /dev/null +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/TreeContent.cs @@ -0,0 +1,38 @@ +// 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 Windows.Foundation; + +namespace Microsoft.CmdPal.Extensions.Helpers; + +public partial class TreeContent : BaseObservable, ITreeContent +{ + public IContent[] Children { get; set; } = []; + + public virtual IContent? RootContent + { + get; + set + { + field = value; + OnPropertyChanged(nameof(RootContent)); + } + } + + public event TypedEventHandler? ItemsChanged; + + public virtual IContent[] GetChildren() => Children; + + protected void RaiseItemsChanged(int totalItems) + { + try + { + // TODO #181 - This is the same thing that BaseObservable has to deal with. + ItemsChanged?.Invoke(this, new ItemsChangedEventArgs(totalItems)); + } + catch + { + } + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions/Microsoft.CmdPal.Extensions.idl b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions/Microsoft.CmdPal.Extensions.idl index 94709a9dd1..1e74509de4 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions/Microsoft.CmdPal.Extensions.idl +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions/Microsoft.CmdPal.Extensions.idl @@ -22,7 +22,7 @@ namespace Microsoft.CmdPal.Extensions String Icon { get; }; Windows.Storage.Streams.IRandomAccessStreamReference Data { get; }; }; - + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] runtimeclass IconInfo { IconInfo(String iconString); @@ -78,9 +78,11 @@ namespace Microsoft.CmdPal.Extensions Dismiss, // Reset the palette to the main page and dismiss GoHome, // Go back to the main page, but keep it open GoBack, // Go back one level - Hide, // Keep this page open, but hide the palette. + Hide, // Keep this page open, but hide the palette. KeepOpen, // Do nothing. GoToPage, // Go to another page. GoToPageArgs will tell you where. + ShowToast, // Display a transient message to the user + Confirm, // Display a confirmation dialog }; enum NavigationMode { @@ -102,15 +104,28 @@ namespace Microsoft.CmdPal.Extensions String PageId { get; }; NavigationMode NavigationMode { get; }; } + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IToastArgs requires ICommandResultArgs{ + String Message { get; }; + ICommandResult Result { get; }; + } + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IConfirmationArgs requires ICommandResultArgs{ + String Title { get; }; + String Description { get; }; + ICommand PrimaryCommand { get; }; + Boolean IsPrimaryCommandCritical { get; }; + } // This is a "leaf" of the UI. This is something that can be "done" by the user. // * A ListPage // * the MoreCommands flyout of for a ListItem or a MarkdownPage [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] interface IInvokableCommand requires ICommand { - ICommandResult Invoke(); + ICommandResult Invoke(Object sender); } + [uuid("ef5db50c-d26b-4aee-9343-9f98739ab411")] [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] interface IFilterItem {} @@ -153,7 +168,6 @@ namespace Microsoft.CmdPal.Extensions OptionalColor Foreground { get; }; OptionalColor Background { get; }; String ToolTip { get; }; - ICommand Command { get; }; }; [uuid("6a6dd345-37a3-4a1e-914d-4f658a4d583d")] @@ -180,6 +194,10 @@ namespace Microsoft.CmdPal.Extensions Windows.Foundation.Uri Link { get; }; String Text { get; }; } + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IDetailsCommand requires IDetailsData { + ICommand Command { get; }; + } [uuid("58070392-02bb-4e89-9beb-47ceb8c3d741")] [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] interface IDetailsSeparator requires IDetailsData {} @@ -231,7 +249,7 @@ namespace Microsoft.CmdPal.Extensions interface IPage requires ICommand { String Title { get; }; Boolean IsLoading { get; }; - + OptionalColor AccentColor { get; }; } @@ -246,7 +264,7 @@ namespace Microsoft.CmdPal.Extensions IconInfo Icon{ get; }; String Title{ get; }; String Subtitle{ get; }; - } + } [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] interface ICommandContextItem requires ICommandItem, IContextItem { @@ -273,7 +291,7 @@ namespace Microsoft.CmdPal.Extensions [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] interface IListPage requires IPage, INotifyItemsChanged { - // DevPal will be responsible for filtering the list of items, unless the + // DevPal will be responsible for filtering the list of items, unless the // class implements IDynamicListPage String SearchText { get; }; String PlaceholderText { get; }; @@ -283,7 +301,7 @@ namespace Microsoft.CmdPal.Extensions Boolean HasMoreItems { get; }; ICommandItem EmptyContent { get; }; - IListItem[] GetItems(); + IListItem[] GetItems(); void LoadMore(); } @@ -312,9 +330,40 @@ namespace Microsoft.CmdPal.Extensions IForm[] Forms(); } + [uuid("b64def0f-8911-4afa-8f8f-042bd778d088")] + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IContent requires INotifyPropChanged { + } + + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IFormContent requires IContent { + String TemplateJson { get; }; + String DataJson { get; }; + String StateJson { get; }; + ICommandResult SubmitForm(String payload); + } + + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IMarkdownContent requires IContent { + String Body { get; }; + } + + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface ITreeContent requires IContent, INotifyItemsChanged { + IContent RootContent { get; }; + IContent[] GetChildren(); + } + + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] + interface IContentPage requires IPage, INotifyItemsChanged { + IContent[] GetContent(); + IDetails Details { get; }; + IContextItem[] Commands { get; }; + } + [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] interface ICommandSettings { - IFormPage SettingsPage { get; }; + IContentPage SettingsPage { get; }; }; [contract(Microsoft.CmdPal.Extensions.ExtensionsContract, 1)] diff --git a/src/modules/cmdpal/exts/PokedexExtension/GlobalSuppressions1.cs b/src/modules/cmdpal/exts/PokedexExtension/GlobalSuppressions1.cs new file mode 100644 index 0000000000..1da6f359bd --- /dev/null +++ b/src/modules/cmdpal/exts/PokedexExtension/GlobalSuppressions1.cs @@ -0,0 +1,8 @@ +// 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.CodeAnalysis; + +[assembly: SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "This is sample code")] +[assembly: SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1649:File name should match first type name", Justification = "This is sample code")] diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleCommentsPage.cs b/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleCommentsPage.cs new file mode 100644 index 0000000000..3eee5e4709 --- /dev/null +++ b/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SampleCommentsPage.cs @@ -0,0 +1,181 @@ +// 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.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.CmdPal.Extensions; +using Microsoft.CmdPal.Extensions.Helpers; + +namespace SamplePagesExtension; + +internal sealed partial class SampleCommentsPage : ContentPage +{ + private readonly TreeContent myContentTree; + + public override IContent[] GetContent() => [myContentTree]; + + public SampleCommentsPage() + { + Name = "View Posts"; + Icon = new("\uE90A"); // Comment + + myContentTree = new() + { + RootContent = new MarkdownContent() + { + Body = """ +# Example of a thread of comments +You can use TreeContent in combination with FormContent to build a structure like a page with comments. + +The forms on this page use the AdaptiveCard `Action.ShowCard` action to show a nested, hidden card on the form. +""", + }, + + Children = [ + new PostContent("First") + { + Replies = [ + new PostContent("Oh very insightful. I hadn't considered that"), + new PostContent("Second"), + new PostContent("ah the ol switcheroo"), + ], + }, + new PostContent("First\nEDIT: shoot") + { + Replies = [ + new PostContent("delete this"), + ], + }, + new PostContent("Do you think they get the picture") + { + Replies = [ + new PostContent("Probably! Now go build and be happy"), + ], + } + ], + }; + } +} + +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class PostContent : TreeContent +{ + public List Replies { get; init; } = []; + + private readonly ToastStatusMessage _toast = new(new StatusMessage() { Message = "Reply posted", State = MessageState.Success }); + + public PostContent(string body) + { + RootContent = new PostForm(body, this); + } + + public override IContent[] GetChildren() => Replies.ToArray(); + + public void Post() + { + RaiseItemsChanged(Replies.Count); + _toast.Show(); + } +} + +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code")] +internal sealed partial class PostForm : FormContent +{ + private readonly PostContent _parent; + + public PostForm(string postBody, PostContent parent) + { + _parent = parent; + TemplateJson = """ +{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.6", + "body": [ + { + "type": "TextBlock", + "text": "${postBody}", + "wrap": true + } + ], + "actions": [ + { + "type": "Action.ShowCard", + "title": "${replyCard.title}", + "card": { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.6", + "body": [ + { + "type": "Container", + "id": "${replyCard.idPrefix}Properties", + "items": [ + { + "$data": "${replyCard.fields}", + "type": "Input.Text", + "label": "${label}", + "id": "${id}", + "isRequired": "${required}", + "isMultiline": true, + "errorMessage": "'${label}' is required" + } + ] + } + ], + "actions": [ + { + "type": "Action.Submit", + "title": "Post" + } + ] + } + }, + { + "type": "Action.Submit", + "title": "Favorite" + }, + { + "type": "Action.Submit", + "title": "View on web" + } + ] +} +"""; + DataJson = $$""" +{ + "postBody": {{JsonSerializer.Serialize(postBody)}}, + "replyCard": { + "title": "Reply", + "idPrefix": "reply", + "fields": [ + { + "label": "Reply", + "id": "ReplyBody", + "required": true, + "placeholder": "Write a reply here" + } + ] + } +} +"""; + } + + public override ICommandResult SubmitForm(string payload) + { + var data = JsonNode.Parse(payload); + _ = data; + var reply = data["ReplyBody"]; + var s = reply?.AsValue()?.ToString(); + if (!string.IsNullOrEmpty(s)) + { + _parent.Replies.Add(new PostContent(s)); + _parent.Post(); + } + + return CommandResult.KeepOpen(); + } +}