command-palette-anatomy.md
1 # Windows Command Palette Anatomy & Extension Development Guide 2 3 ## Root View 4 5 Let's start with the root view of the Command Palette. The root view is the view that is displayed when the Command Palette is first opened. It is the first thing the user sees: 6 7  8 9 The root view is a special kind of [ListPage](#listpage). It's special because it displays all of the top level commands: [built-in commands](INSERT SECTION SOMEWHERE) and [commands provided by installed extensions](#icommandprovider). 10 11  12 13 In this context, commands are just [ListItems](#listitem) that are displayed in the root view of Command Palette. In other words, all of the ListItems in the root view are known as commands. 14 15 Users can search/filter for specific commands by typing in the [FilterBox](#filterbox). 16 17  18 19 The filter box will filter the commands displayed in the root view based on the user's input: 20 21  22 23 24 25 When a user clicks on a command, the [default command](#default-command-this-is-probably-not-the-right-spot-for-this) is executed. Alternatively, the user can press the `Enter` key to execute the default command associated with the selected command. 26 27 ## Nested Views (Pages) 28 29 When a command is executed, it can open a new "nested" view inside of the Command Palette. These views are known as pages. 30 31 ### ListPage 32 33 A list page is a page inside of command palette that displays a list of items. It has the following interface: 34 35 ```csharp 36 interface IListPage requires IPage { 37 String SearchText { get; }; 38 String PlaceholderText { get; }; 39 Boolean ShowDetails{ get; }; 40 IFilters Filters { get; }; 41 IGridProperties GridProperties { get; }; 42 43 ISection[] GetItems(); // DevPal will be responsible for filtering the list of items 44 } 45 ``` 46 47 #### ISection 48 49 #### GridProperties 50 51 #### ShowDetails 52 53 #### PlaceholderText 54 55 #### SearchText 56 57 ### MarkdownPage 58 59 A markdown page is a page inside of command palette that displays markdown content. It has the following interface: 60 61 ```csharp 62 interface IMarkdownPage requires IPage { 63 String[] Bodies(); // TODO! should this be an IBody, so we can make it observable? 64 IDetails Details(); 65 IContextItem[] Commands { get; }; 66 } 67 ``` 68 69 ### FormPage 70 71 A form page is a page inside of command palette that displays a form. It has the following interface: 72 73 ```csharp 74 interface IFormPage requires IPage { 75 IForm[] Forms(); 76 } 77 ``` 78 79 ### Dynamic List Page 80 81 A dynamic list page is similar to a list page, but it gives the developer control over how the list is filtered. It has the following interface: 82 83 ```csharp 84 interface IDynamicListPage requires IListPage { 85 ISection[] GetItems(String query); // DevPal will do no filtering of these items 86 } 87 ``` 88 89 ## Additional Components 90 91 92 ### ListItem 93 94 List items are items that are displayed on a [ListPage](#listpage). They can be clicked on to execute a command. 95 96 #### Tags 97 98 ### FilterBox 99 100 ## Anatomy of an Extension 101 102 #### ICommandProvider 103 104 This is the interface that an extension implements to provide top level commands to the Command Palette. 105 106 **This is the entry point for all extensions into Command Palette.** 107 108 ```csharp 109 public interface ICommandProvider 110 { 111 IEnumerable<ICommand> GetCommands(); 112 } 113 ``` 114 115 #### ICommand 116 117 This is the backbone of the Command Palette. It represents a unit of work that can be executed by the Command Palette. 118 119 ListItems and ContextItems leverage the ICommand interface to execute commands. 120 121 ```csharp 122 public interface ICommand 123 { 124 string Name { get; } 125 void Execute(); 126 } 127 ``` 128 129 #### Default Command (this is probably not the right spot for this) 130 131 ### Building an Extension Example - SSH Keychain 132 133 So you want to build an extension for the Command Palette? Let's walk through an example. 134 135 #### Overview 136 137 First, let's define the requirements for the SSH Keychain extension: 138 139 1. The extension should provide a top level command in the Command Palette: "Search SSH Keys". 140 2. When the "Search SSH Keys" command is executed, a new page should be displayed in the Command Palette that lists all of the SSH hosts on the user's machine. 141 3. The user should be able to click on an SSH host or press `Enter` as the default command to launch Windows Terminal with an SSH session to the selected host. 142 143 When we're done building the extension, the Command Palette should look something like this: 144 145  146 147 #### Getting Started 148 149 We've made it easy to build a new extension. Just follow these steps: 150 151 1. Navigate to the src\modules\cmdpal folder 152 2. Run the following PowerShell script, replacing "MastodonExtension" with the `Name` of your extension and "Mastodon extension for cmdpal" with the `DisplayName` of the [command that will show up in the root view](#root-view) of the Command Palette: 153 154 ```powershell 155 .\ext\NewExtension.ps1 -name MastodonExtension -DisplayName "Mastodon extension for cmdpal" 156 ``` 157 158 3. Open the solution in Visual Studio. 159 4. Right click on the directory titled 'SampleExtensions' and select 'Add' -> 'Existing Project'. 160 161  162 163 5. Navigate to the '\Exts folder, and find the *.csproj file that was created by the PowerShell script. It will be in a folder with the same name as the extension you provided in the PowerShell script. 164 6. Right click on the new project you've just created and select 'Deploy'. 165 7. Build the project by clicking the play button in Visual Studio. 166 167  168 169 At this point you should have your command that you created show up in the root view of the Command Palette! If you've set up everything correctly, it should look something like this (note - your example won't have icons yet): 170 171 172  173 174 #### Step 1: Provide a Top Level Command 175 176 In order for users to interact with your extension in the Command Palette, **you need to provide at least one top level command**. To do this, implement the `ICommandProvider` interface located in the `*CommandsProvider.cs` file created by the extension template. This interface has a single method, `TopLevelCommands()`, that returns a list of commands that will be displayed in the root view of the Command Palette. Remember, each top level command that a user sees is represented by a [ListItem](#listitem). In other words, the `TopLevelCommands()` method will return a list of `ListItems`, where each `ListItem` represents a command that will be displayed in the [root view](#root-view) of the Command Palette. 177 178 ```csharp 179 public class SSHKeychainCommandsProvider : CommandProvider 180 { 181 public SSHKeychainCommandsProvider() 182 { 183 DisplayName = "SSH Keychain Commands"; 184 } 185 186 private readonly ICommandItem[] _commands = [ 187 new CommandItem(new NoOpCommand()) 188 { 189 Title = "Search SSH Keys", 190 Subtitle = "Quickly find and launch into hosts from your SSH config file", 191 }, 192 ]; 193 194 public override ICommandItem[] TopLevelCommands() 195 { 196 return _commands; 197 } 198 } 199 ``` 200 201 We recommend that you follow the template guidelines and put your implementation for the ICommandProvider inside of its own file . In this case, we have created an `SSHKeychainCommandProvider` class that will provide the required "Search SSH Keys" command inside of `SSHKeychainCommandProvider.cs`. 202 203 Let's see what this currently looks like in the Command Palette. First, deploy your extension in Visual Studio by right-clicking on the project and selecting "Deploy": 204 205  206 207 Then, open the Command Palette by pressing `Win+Ctrl+.` and search for "Search SSH Keys". You should see the command displayed in the root view of the Command Palette like this: 208 209  210 211 And there you have it. We've successfully provided a top level command in the Command Palette! Go ahead and try to change the title and subtitle of the ListItem to see how it affects the display in the Command Palette! **You'll have to rebuild your project and deploy it again to see the changes.** 212 213 #### Step 2: Implement the Command Execution 214 215 In Step 1 we provided a top level command in the Command Palette, but it doesn't currently do anything. That's because the ListItem we've provided didn't provide any associated commands that could be executed! 216 217 ```csharp 218 private readonly IListItem[] _commands = [ 219 new ListItem(new NoOpCommand()) 220 { 221 Title = "Search SSH Keys", 222 Subtitle = "Quickly find and launch into hosts from your SSH config file", 223 }, 224 ]; 225 ``` 226 Notice how in the snippet above, we've provided a `NoOpCommand()` value for the `Command` property of the `ListItem`. This is why the command doesn't currently do anything. 227 228 So, we now need to implement the command execution logic. When the user clicks on the "Search SSH Keys" command, we want to display a new [page](#nested-views-pages) in the Command Palette that lists all of the SSH hosts on the user's machine. 229 230 Let's start by providing a new [ListPage](#listpage) command that will be executed by default when a user either clicks on the "Search SSH Keys" ListItem or has the ListItem selected and presses `Enter`. For now, let's just display a simple message to the user in the new page when the command is executed. 231 232 Following the template guidelines, we will create a new file inside of the 'Pages' folder. All of the [pages](#nested-views-pages) your extension uses should be located in the pages folder. In this case, we will create a new class called `SSHHostsListPage` because we will be displaying a list of SSH hosts on this page. 233 234 ```csharp 235 internal sealed class SSHHostsListPage : ListPage 236 { 237 public SSHHostsListPage() 238 { 239 Icon = new(string.Empty); 240 Name = "SSH Keychain"; 241 } 242 243 public override ISection[] GetItems() 244 { 245 return [ 246 new ListSection() 247 { 248 Title = "SSH Hosts", 249 Items = [ 250 new ListItem(new NoOpCommand()) { Title = "TODO: Implement your extension here" } 251 ], 252 } 253 ]; 254 } 255 } 256 ``` 257 258 A list page expects an array of [`ISection`](#isection) objects to be returned from the `GetItems()` method. Take a look at the HackerNewsPage as an example of what a ListPage with a section might look like: 259 260  261 262 In the HackerNews example, we return one section with a bunch of list items. However, in our example code above, we are returning a single `ListSection` with a single `ListItem` that has a `NoOpCommand` associated with it. The `NoOpCommand` is a simple action that does nothing when executed. Here is a reminder of the ListSection we are returning: 263 264 ```csharp 265 new ListSection() 266 { 267 Title = "SSH Hosts", 268 Items = [ 269 new ListItem(new NoOpCommand()) { Title = "TODO: Implement your extension here" } 270 ], 271 } 272 ``` 273 274 In order for our root command, "Search SSH Hosts" to execute the command to our new page, we need to update the `SSHKeychainCommandsProvider` class from [step 1](#step-1-provide-a-top-level-command) to provide the `SSHHostsListPage` command when the "Search SSH Keys" list item has its default command executed. Specifically, we need to update the `Command` property of the `ListItem` to be the `SSHHostsListPage` class we just created. Inside of SSHKeychainCommandsProvider.cs, the code should look like this: 275 276 ```csharp 277 // Code in Provider before we update the Command property of the ListItem. There is no default command associated with the ListItem. 278 279 private readonly IListItem[] _commands = [ 280 new ListItem(new NoOpCommand()) 281 { 282 Title = "Search SSH Keys", 283 Subtitle = "Quickly find and launch into hosts from your SSH config file", 284 }, 285 ]; 286 287 // Code in Provider after we update the Command property of the ListItem. We are updating the default command. 288 289 private readonly IListItem[] _commands = [ 290 new ListItem(new SSHHostsListPage()) 291 { 292 Title = "Search SSH Keys", 293 Subtitle = "Quickly find and launch into hosts from your SSH config file", 294 }, 295 ]; 296 ``` 297 298 Let's rebuild our project and see how the changes appear in Command Palette. 299 300 You should see the `Search SSH Keys` Command in the root view of the palette like this: 301 302  303 304 Now when you [invoke the default command](#default-command-this-is-probably-not-the-right-spot-for-this) to be executed for the `Search SSH Keys` command in the root view, you should see a new page displayed in the Command Palette with the message "TODO: Implement your extension here": 305 306  307 308 - When the "Search SSH Keys" command is executed, a new page should be displayed in the Command Palette ✅ 309 - TODO: Implement the logic that will parse the SSH config file to actually display the available SSH hosts on the machine in the Command Palette. 310 311 Before we get to implementing the logic to display the SSH hosts, let's take this example one step farther by providing a few list items that will be displayed in the `SSHHostsListPage`. 312 313 ```csharp 314 internal sealed class SSHHostsListPage : ListPage 315 { 316 public SSHHostsListPage() 317 { 318 Icon = new(string.Empty); 319 Name = "SSH Keychain"; 320 } 321 322 public override ISection[] GetItems() 323 { 324 return [ 325 new ListSection() 326 { 327 Title = "SSH Hosts", 328 Items = [ 329 new ListItem(new NoOpCommand()) { Title = "TODO: Implement your extension here" }, 330 new ListItem(new NoOpCommand()) { Title = "Another list item", Subtitle = "this one has a subtitle" }, 331 new ListItem(new NoOpCommand()) 332 { 333 Title = "A list item with a tag", 334 Subtitle = "this one with tags", 335 Tags = [new Tag() 336 { 337 Text = "Example Tag", 338 } 339 ], 340 } 341 ], 342 } 343 ]; 344 } 345 } 346 ``` 347 348 In the snippet above, we've added two additional `ListItem` objects to the `Items` array of the `ListSection`. Now the second `ListItem` has a `Subtitle` property set, and the third `ListItem` has both a subtitle set and a [`Tag`](#tags) associated with it. 349 350 Let's take a look at how this change looks in the Command Palette: 351 352  353 354 Following the documentation above, we're at the point where we can: 355 356 - Add a top level command to the Command Palette ✅ 357 - Display a new `ListPage` in the Command Palette when the command is executed ✅ 358 - Display a list of items in the new page ✅ 359 360 For the SSH Keychain extension, we want to display a list of SSH hosts on the user's machine. We'll then replace these example `ListItem` objects with the actual SSH hosts that we parse from the SSH config file. 361 362 First, let's add a file to the 'Data' folder called `SSHKeychainItem.cs`. This file will contain a class that represents the information that we need to display for each SSH host. 363 364 ```csharp 365 internal class SSHKeychainItem 366 { 367 internal string Host { get; init; } 368 369 internal string EscapedHost => JsonEncodedText.Encode(Host).ToString(); 370 } 371 ``` 372 373 We recommend that you add any data classes that you need to the 'Data' folder of your extension. This will help keep your project organized. 374 375 Next, we need to update the `SSHHostsListPage` class to display the actual SSH hosts that we parse from the SSH config file rather than the sample list items we've shown before. We'll need to update the `GetItems()` method to return a list of `ListItem` objects that represent the actual SSH hosts. The entire `SSHHostsListPage` class should look like this: 376 377 ```csharp 378 internal sealed partial class SSHHostsListPage : ListPage 379 { 380 private static readonly string _defaultConfigFile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "\\.ssh\\config"; 381 382 private static readonly Regex _hostRegex = new(@"^Host\s+(?:(\S*) ?)*?\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline); 383 384 public SSHHostsListPage() 385 { 386 Icon = new(string.Empty); 387 Name = "SSH Keychain"; 388 } 389 390 private static async Task<List<SSHKeychainItem>> GetSSHHosts() 391 { 392 var hosts = new List<SSHKeychainItem>(); 393 var configFile = _defaultConfigFile; 394 395 if (!File.Exists(configFile)) 396 { 397 return hosts; 398 } 399 400 var options = new FileStreamOptions() 401 { 402 Access = FileAccess.Read, 403 }; 404 405 using var reader = new StreamReader(configFile, options); 406 var fileContent = await reader.ReadToEndAsync(); 407 408 if (!string.IsNullOrEmpty(fileContent)) 409 { 410 var matches = _hostRegex.Matches(fileContent); 411 hosts = matches.Select(match => new SSHKeychainItem { HostName = match.Groups[1].Value }).ToList(); 412 } 413 414 return hosts; 415 } 416 417 public override ISection[] GetItems() 418 { 419 var t = DoGetItems(); 420 t.ConfigureAwait(false); 421 return t.Result; 422 } 423 424 private async Task<ISection[]> DoGetItems() 425 { 426 List<SSHKeychainItem> items = await GetSSHHosts(); 427 var s = new ListSection() 428 { 429 Title = "SSH Hosts", 430 Items = items.Select((host) => new ListItem(new NoOpCommand()) 431 { 432 Title = host.HostName, 433 Subtitle = host.EscapedHost, 434 }).ToArray(), 435 }; 436 return [s]; 437 } 438 } 439 ``` 440 441 Let's break down what's happening in the `SSHHostsListPage` class: 442 443 1. We've added a `GetSSHHosts()` method that reads the SSH config file and parses out the host names. This method returns a list of `SSHKeychainItem` objects that represent the SSH hosts on the user's machine. **Notice how this is an async operation!** 444 445 2. GetItems() now calls DoGetItems() which is an async method that gets the SSH hosts and creates a `ListSection` object with a list of `ListItem` objects that represent the SSH hosts. **If you're planning on doing any async operations in your `GetItems()` method, you should follow this pattern** 446 447 3. Inside of DoGetItems(), we call GetSSHHosts() to get the SSH hosts and then create a `ListSection` object with a list of `ListItem` objects that represent the SSH hosts. We are able to then create the proper title and subtitle for each `ListItem` object. 448 449 Now that we've updated the `SSHHostsListPage` class to display the actual SSH hosts on the user's machine, let's rebuild our project and see how the changes appear in the Command Palette. **NOTE: You will need to have an SSH config file on your machine for this to work.** 450 451  452 453 Awesome! We've successfully displayed a list of SSH hosts on the user's machine in the Command Palette! We can even search for specific hosts by typing in the filter box: 454 455  456 457 Filtering the list of SSH hosts is a built-in feature of the Command Palette. You don't have to do anything special to enable this functionality. The Command Palette will automatically filter the list of items displayed in the Command Palette based on the user's input in the filter box using a fuzzy search. However, if you'd like to handle the filtering yourself, you can create a [Dynamic List Page](#dynamic-list-page) instead of a [List Page](#listpage). 458 459 #### Step 3: Implement Command for SSH Hosts 460 461 Now that we have a list of SSH hosts displayed in the Command Palette, we want to be able to launch Windows Terminal with an SSH session to the selected host when the user clicks on a host or presses `Enter` as the default command. 462 463 To do this, we need to update the `ListItem` objects in the `SSHHostsListPage` class to have a command associated with them that will launch Windows Terminal with an SSH session to the selected host. We'll create a new class called `LaunchSSHSessionCommand` that will be responsible for launching Windows Terminal with an SSH session to the selected host. 464 465 Following the template guidelines, we will create a new file inside of the 'Commands' folder. In this case, we will create a new class called `LaunchSSHSessionCommand` that will be responsible for launching Windows Terminal with an SSH session to the selected host: 466 467 ```csharp 468 internal sealed partial class LaunchSSHHostCommand : InvokableCommand 469 { 470 private readonly SSHKeychainItem _host; 471 472 internal LaunchSSHHostCommand(SSHKeychainItem host) 473 { 474 this._host = host; 475 this.Name = "Connect"; 476 this.Icon = new("\uE8A7"); 477 } 478 479 public override CommandResult Invoke() 480 { 481 try 482 { 483 Process.Start("cmd.exe", $"/k ssh {_host.HostName}"); 484 } 485 catch 486 { 487 Process.Start(new ProcessStartInfo("cmd.exe", $"/k ssh {_host.HostName}") { UseShellExecute = true }); 488 } 489 490 return CommandResult.KeepOpen(); 491 } 492 } 493 ``` 494 495 That's it! We've successfully implemented the command that will launch Windows Terminal with an SSH session to the selected host. Now we need to update the `ListItem` objects in the `SSHHostsListPage` class to have the `LaunchSSHSessionCommand` associated with them. Specifically, we need to update the `Command` property of the `ListItem` objects to be the `LaunchSSHSessionCommand` class we just created. Inside of `SSHHostsListPage.cs`, the code should look like this: 496 497 ```csharp 498 private async Task<ISection[]> DoGetItems() 499 { 500 List<SSHKeychainItem> items = await GetSSHHosts(); 501 var s = new ListSection() 502 { 503 Title = "SSH Hosts", 504 Items = items.Select((host) => new ListItem(new LaunchSSHHostCommand(host)) 505 { 506 Title = host.HostName, 507 Subtitle = host.EscapedHost, 508 }).ToArray(), 509 }; 510 return [s]; 511 } 512 ``` 513 514 All we had to change was the `NoOpCommand` to the `LaunchSSHHostCommand` in the `ListItem` object. Now when the user clicks on a host or presses `Enter` as the default command, Windows Terminal will launch with an SSH session to the selected host! Take a look at how this looks in the Command Palette: 515 516 