Add a command for quickly creating a new extension (#423)

This adds a new form page for quickly creating a new extension. 

* `ExtensionTemplate/` should be zipped up into `template.zip`. 
* fill in that form, then when you submit the form, we'll unzip the template. 
* You should be able to just open that solution up and just **go**
* I moved the built-in commands lower in the list. The only visible commands it exposes are "Open CmdPal settings" and "Create new extension" (the others are hidden fallbacks, so it doesn't really matter)
* To mitigate that the settings command is lower in the list, I added it to the "page title" spot in the command bar (only on the root view). 

Closes #311
This commit is contained in:
Mike Griese
2025-02-13 11:43:19 -06:00
committed by GitHub
parent ef58863fac
commit cd5a541b3e
21 changed files with 540 additions and 28 deletions

View File

@@ -3,14 +3,10 @@
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="SDKLocalSource" value=".\deps\Microsoft.CommandPalette.Extensions" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="SDKLocalSource">
<package pattern="Microsoft.CommandPalette.Extensions" />
</packageSource>
</packageSourceMapping>
</configuration>

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -7,7 +7,7 @@ using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WinGet;
namespace Microsoft.CmdPal.Common;
public partial class ExtensionHostInstance
{

View File

@@ -2,7 +2,6 @@
// 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.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -18,10 +17,12 @@ public partial class BuiltInsCommandProvider : CommandProvider
private readonly QuitCommand quitCommand = new();
private readonly FallbackReloadItem _fallbackReloadItem = new();
private readonly FallbackLogItem _fallbackLogItem = new();
private readonly NewExtensionPage _newExtension = new();
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { Subtitle = "Open Command Palette settings" },
new CommandItem(_newExtension) { Title = _newExtension.Title, Subtitle = "Creates a project for a new Command Palette extension" },
];
public override IFallbackCommandItem[] FallbackCommands() =>
@@ -37,4 +38,6 @@ public partial class BuiltInsCommandProvider : CommandProvider
DisplayName = "Built-in commands";
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
}
public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host);
}

View File

@@ -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.
using System.Diagnostics.CodeAnalysis;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class BuiltinsExtensionHost
{
internal static ExtensionHostInstance Instance { get; } = new();
}

View File

@@ -0,0 +1,148 @@
// 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.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal sealed partial class CreatedExtensionForm : NewExtensionFormBase
{
public CreatedExtensionForm(string name, string displayName, string path)
{
TemplateJson = CardTemplate;
DataJson = $$"""
{
"name": {{JsonSerializer.Serialize(name)}},
"directory": {{JsonSerializer.Serialize(path)}},
"displayName": {{JsonSerializer.Serialize(displayName)}}
}
""";
_name = name;
_displayName = displayName;
_path = path;
}
public override ICommandResult SubmitForm(string inputs, string data)
{
var dataInput = JsonNode.Parse(data)?.AsObject();
if (dataInput == null)
{
return CommandResult.KeepOpen();
}
var verb = dataInput["x"]?.AsValue()?.ToString() ?? string.Empty;
return verb switch
{
"sln" => OpenSolution(),
"dir" => OpenDirectory(),
"new" => CreateNew(),
_ => CommandResult.KeepOpen(),
};
}
private ICommandResult OpenSolution()
{
string[] parts = [_path, _name, $"{_name}.sln"];
var pathToSolution = Path.Combine(parts);
ShellHelpers.OpenInShell(pathToSolution);
return CommandResult.GoHome();
}
private ICommandResult OpenDirectory()
{
string[] parts = [_path, _name];
var pathToDir = Path.Combine(parts);
ShellHelpers.OpenInShell(pathToDir);
return CommandResult.GoHome();
}
private ICommandResult CreateNew()
{
RaiseFormSubmit(null);
return CommandResult.KeepOpen();
}
private static readonly string CardTemplate = """
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.6",
"body": [
{
"type": "TextBlock",
"text": "Successfully created your new extension!",
"size": "large",
"weight": "bolder",
"style": "heading",
"wrap": true
},
{
"type": "TextBlock",
"text": "Your new extension \"${displayName}\" was created in:",
"wrap": true
},
{
"type": "TextBlock",
"text": "${directory}",
"fontType": "monospace"
},
{
"type": "TextBlock",
"text": "Next steps",
"style": "heading",
"wrap": true
},
{
"type": "TextBlock",
"text": "Now that your extension project has been created, open the solution up in Visual Studio to start writing your extension code.",
"wrap": true
},
{
"type": "TextBlock",
"text": "Navigate to `${name}Page.cs` to start adding items to the list, or to `${name}CommandsProvider.cs` to add new commands.",
"wrap": true
},
{
"type": "TextBlock",
"text": "Once you're ready to test deploy the package locally with Visual Studio, then run the \"Reload\" command in the Command Palette to load your new extension.",
"wrap": true
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Open Solution",
"data": {
"x": "sln"
}
},
{
"type": "Action.Submit",
"title": "Open directory",
"data": {
"x": "dir"
}
},
{
"type": "Action.Submit",
"title": "Create another",
"data": {
"x": "new"
}
}
]
}
""";
private readonly string _name;
private readonly string _displayName;
private readonly string _path;
}

View File

@@ -0,0 +1,216 @@
// 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.IO.Compression;
using System.Text.Json.Nodes;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal sealed partial class NewExtensionForm : NewExtensionFormBase
{
private static readonly string _creatingText = "Creating new extension...";
private readonly StatusMessage _creatingMessage = new()
{
Message = _creatingText,
Progress = new ProgressState() { IsIndeterminate = true },
};
public NewExtensionForm()
{
TemplateJson = $$"""
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.6",
"body": [
{
"type": "TextBlock",
"text": "Create your new extension",
"size": "large"
},
{
"type": "TextBlock",
"text": "Use this page to create a new extension project.",
"wrap": true
},
{
"type": "TextBlock",
"text": "Extension name",
"weight": "bolder",
"size": "default"
},
{
"type": "TextBlock",
"text": "This is the name of your new extension project. It should be a valid C# class name. Best practice is to also include the word 'Extension' in the name.",
"wrap": true
},
{
"type": "Input.Text",
"label": "Extension name",
"isRequired": true,
"errorMessage": "Extension name is required, without spaces",
"id": "ExtensionName",
"placeholder": "ExtensionName",
"regex": "^[^\\s]+$"
},
{
"type": "TextBlock",
"text": "Display name",
"weight": "bolder",
"size": "default"
},
{
"type": "TextBlock",
"text": "The name of your extension as users will see it.",
"wrap": true
},
{
"type": "Input.Text",
"label": "Display name",
"isRequired": true,
"errorMessage": "Display name is required",
"id": "DisplayName",
"placeholder": "My new extension"
},
{
"type": "TextBlock",
"text": "Output path",
"weight": "bolder",
"size": "default"
},
{
"type": "TextBlock",
"text": "Where should the new extension be created? This path will be created if it doesn't exist",
"wrap": true
},
{
"type": "Input.Text",
"label": "Output path",
"isRequired": true,
"errorMessage": "Output path is required",
"id": "OutputPath",
"placeholder": "C:\\users\\me\\dev"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Create extension",
"associatedInputs": "auto"
}
]
}
""";
}
public override CommandResult SubmitForm(string payload)
{
var formInput = JsonNode.Parse(payload)?.AsObject();
if (formInput == null)
{
return CommandResult.KeepOpen();
}
var extensionName = formInput["ExtensionName"]?.AsValue()?.ToString() ?? string.Empty;
var displayName = formInput["DisplayName"]?.AsValue()?.ToString() ?? string.Empty;
var outputPath = formInput["OutputPath"]?.AsValue()?.ToString() ?? string.Empty;
_creatingMessage.State = MessageState.Info;
_creatingMessage.Message = _creatingText;
_creatingMessage.Progress = new ProgressState() { IsIndeterminate = true };
BuiltinsExtensionHost.Instance.ShowStatus(_creatingMessage);
try
{
CreateExtension(extensionName, displayName, outputPath);
// _creatingMessage.Progress = null;
// _creatingMessage.State = MessageState.Success;
// _creatingMessage.Message = $"Successfully created extension";
BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
// BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
RaiseFormSubmit(new CreatedExtensionForm(extensionName, displayName, outputPath));
// _toast.Message.State = MessageState.Success;
// _toast.Message.Message = $"Successfully created extension";
// _toast.Show();
}
catch (Exception e)
{
BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
_creatingMessage.State = MessageState.Error;
_creatingMessage.Message = $"Error: {e.Message}";
// _toast.Show();
}
// _ = Task.Run(() =>
// {
// Thread.Sleep(2500);
// BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
// });
return CommandResult.KeepOpen();
}
private void CreateExtension(string extensionName, string newDisplayName, string outputPath)
{
var newGuid = Guid.NewGuid().ToString();
// Unzip `template.zip` to a temp dir:
var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
// Console.WriteLine($"Extracting to {tempDir}");
// Does the output path exist?
if (!Directory.Exists(outputPath))
{
Directory.CreateDirectory(outputPath);
}
var assetsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory.ToString(), "Microsoft.CmdPal.UI.ViewModels\\Assets\\template.zip");
ZipFile.ExtractToDirectory(assetsPath, tempDir);
var files = Directory.GetFiles(tempDir, "*", SearchOption.AllDirectories);
foreach (var file in files)
{
var text = File.ReadAllText(file);
Console.WriteLine($" Processing {file}");
// Replace all the instances of `FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF` with a new random guid:
text = text.Replace("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF", newGuid);
// Then replace all the `TemplateCmdPalExtension` with `extensionName`
text = text.Replace("TemplateCmdPalExtension", extensionName);
// Then replace all the `TemplateDisplayName` with `newDisplayName`
text = text.Replace("TemplateDisplayName", newDisplayName);
// We're going to write the file to the same relative location in the output path
var relativePath = Path.GetRelativePath(tempDir, file);
var newFileName = Path.Combine(outputPath, relativePath);
// if the file name had `TemplateCmdPalExtension` in it, replace it with `extensionName`
newFileName = newFileName.Replace("TemplateCmdPalExtension", extensionName);
// Make sure the directory exists
Directory.CreateDirectory(Path.GetDirectoryName(newFileName)!);
File.WriteAllText(newFileName, text);
Console.WriteLine($" Wrote {newFileName}");
// Delete the old file
File.Delete(file);
}
// Delete the temp dir
Directory.Delete(tempDir, true);
}
}

View File

@@ -0,0 +1,21 @@
// 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.IO.Compression;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
internal abstract partial class NewExtensionFormBase : FormContent
{
public event TypedEventHandler<NewExtensionFormBase, NewExtensionFormBase?>? FormSubmitted;
protected void RaiseFormSubmit(NewExtensionFormBase? next) => FormSubmitted?.Invoke(this, next);
}

View File

@@ -0,0 +1,49 @@
// 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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
public partial class NewExtensionPage : ContentPage
{
private NewExtensionForm _inputForm = new();
private NewExtensionFormBase? _resultForm;
public override IContent[] GetContent()
{
return _resultForm != null ? [_resultForm] : [_inputForm];
}
public NewExtensionPage()
{
Name = "Open";
Title = "Create a new extension";
Icon = new IconInfo("\uEA86"); // Puzzle
_inputForm.FormSubmitted += FormSubmitted;
}
private void FormSubmitted(NewExtensionFormBase sender, NewExtensionFormBase? args)
{
if (_resultForm != null)
{
_resultForm.FormSubmitted -= FormSubmitted;
}
_resultForm = args;
if (_resultForm != null)
{
_resultForm.FormSubmitted += FormSubmitted;
}
else
{
_inputForm = new();
_inputForm.FormSubmitted += FormSubmitted;
}
RaiseItemsChanged(1);
}
}

View File

@@ -83,19 +83,22 @@ public partial class ContentFormViewModel(IFormContent _form, IPageContext conte
var dataString = (action as AdaptiveSubmitAction)?.DataJson.Stringify() ?? string.Empty;
var inputString = inputs.Stringify();
try
_ = Task.Run(() =>
{
var model = _formModel.Unsafe!;
if (model != null)
try
{
var result = model.SubmitForm(inputString, dataString);
WeakReferenceMessenger.Default.Send<HandleCommandResultMessage>(new(new(result)));
var model = _formModel.Unsafe!;
if (model != null)
{
var result = model.SubmitForm(inputString, dataString);
WeakReferenceMessenger.Default.Send<HandleCommandResultMessage>(new(new(result)));
}
}
}
catch (Exception ex)
{
PageContext.ShowException(ex);
}
catch (Exception ex)
{
PageContext.ShowException(ex);
}
});
}
}

View File

@@ -34,4 +34,8 @@
</ItemGroup>
<ItemGroup>
<None Remove="Assets\template.zip" />
</ItemGroup>
</Project>

View File

@@ -85,7 +85,6 @@ public partial class App : Application
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
services.AddSingleton<ICommandProvider, IndexerCommandsProvider>();
services.AddSingleton<ICommandProvider, BuiltInsCommandProvider>();
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>();
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();
@@ -94,6 +93,7 @@ public partial class App : Application
services.AddSingleton<ICommandProvider, WindowsSettingsCommandsProvider>();
services.AddSingleton<ICommandProvider, RegistryCommandsProvider>();
services.AddSingleton<ICommandProvider, WindowsServicesCommandsProvider>();
services.AddSingleton<ICommandProvider, BuiltInsCommandProvider>();
// Models
services.AddSingleton<TopLevelCommandManager>();

View File

@@ -20,6 +20,10 @@
x:Key="StringNotEmptyToVisibilityConverter"
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
<converters:BoolToVisibilityConverter
x:Key="BoolToInvertedVisibilityConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
<cmdpalUI:MessageStateToSeverityConverter x:Key="MessageStateToSeverityConverter" />
@@ -65,7 +69,8 @@
<Grid
x:Name="IconRoot"
Margin="8,0,0,0"
Tapped="PageIcon_Tapped">
Tapped="PageIcon_Tapped"
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}">
<cpcontrols:IconBox
x:Name="IconBorder"
@@ -105,12 +110,32 @@
</Flyout>
</Grid.ContextFlyout>
</Grid>
<Button
x:Name="SettingsIconButton"
Margin="0,0,0,0"
Style="{StaticResource SubtleButtonStyle}"
Tapped="SettingsIcon_Tapped"
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontSize="16"
Glyph="&#xE713;" />
<TextBlock
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}"
Text="Settings" />
</StackPanel>
</Button>
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
FontSize="12"
Text="{x:Bind CurrentPageViewModel.Title, Mode=OneWay}" />
Text="{x:Bind CurrentPageViewModel.Title, Mode=OneWay}"
Visibility="{x:Bind CurrentPageViewModel.IsNested, Mode=OneWay}" />
<!-- TO DO: Replace with ItemsRepeater and bind "Primary commands"? -->
<StackPanel
Grid.Column="2"

View File

@@ -113,4 +113,10 @@ public sealed partial class CommandBar : UserControl,
showOptions: new FlyoutShowOptions() { ShowMode = FlyoutShowMode.Standard });
}
}
private void SettingsIcon_Tapped(object sender, TappedRoutedEventArgs e)
{
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>();
e.Handled = true;
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ContentFormControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -13,11 +13,10 @@
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<converters:StringVisibilityConverter
x:Key="StringNotEmptyToVisibilityConverter"
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
<ResourceDictionary x:Name="CardOverrideStyles">
<Style x:Key="Adaptive.TextBlock" TargetType="TextBlock">
<Setter Property="IsTextSelectionEnabled" Value="True" />
</Style>
</ResourceDictionary>
</UserControl.Resources>

View File

@@ -24,7 +24,13 @@ public sealed partial class ContentFormControl : UserControl
static ContentFormControl()
{
_renderer = new AdaptiveCardRenderer();
// We can't use `CardOverrideStyles` here yet, because we haven't called InitializeComponent once.
// But also, the default value isn't `null` here. It's... some other default empty value.
// So clear it out so that we know when the first time we get created is
_renderer = new AdaptiveCardRenderer()
{
OverrideStyles = null,
};
}
public ContentFormControl()
@@ -33,6 +39,14 @@ public sealed partial class ContentFormControl : UserControl
var lightTheme = ActualTheme == Microsoft.UI.Xaml.ElementTheme.Light;
_renderer.HostConfig = lightTheme ? AdaptiveCardsConfig.Light : AdaptiveCardsConfig.Dark;
// 5% bodgy: if we set this multiple times over the lifetime of the app,
// then the second call will explode, because "CardOverrideStyles is already the child of another element".
// SO only set this once.
if (_renderer.OverrideStyles == null)
{
_renderer.OverrideStyles = CardOverrideStyles;
}
// TODO in the future, we should handle ActualThemeChanged and replace
// our rendered card with one for that theme. But today is not that day
}

View File

@@ -166,6 +166,11 @@
<Content Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.ObjectModel.WinUI3.dll" Link="AdaptiveCards.ObjectModel.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" />
<Content Include="$(PkgAdaptiveCards_Rendering_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.Rendering.WinUI3.dll" Link="AdaptiveCards.Rendering.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<Content Update="..\Microsoft.CmdPal.UI.ViewModels\Assets\template.zip">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Page Update="Styles\Settings.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -27,7 +27,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
</ItemGroup>
<!--

View File

@@ -2,6 +2,8 @@
// 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.Common;
namespace Microsoft.CmdPal.Ext.WinGet;
public partial class WinGetExtensionHost