Using PowerShellRun as an Interactive Selector

Using PowerShellRun as an Interactive Selector

·

6 min read

In my previous blog post, I introduced PowerShellRun module as a terminal based launcher application. That is the main purpose of the module but the underlying fuzzy selector can also be used as a generic selector. In this post, we’ll see some use cases by building small tools using the selector.

💡
In this post, we use PowerShellRun v0.11.0 on Windows 11.

What is an Interactive Selector?

An interactive selector is a tool that takes objects as inputs, shows you a UI to select the objects and returns the selected objects. In some of the popular modules, Out-ConsoleGridView and Out-GridView -PassThru fall into this category, but we use PowerShellRun as it gives you more customizability.

In PowerShellRun, Invoke-PSRunSelector works as an interactive selector. The following command lists processes and lets you select a process through a TUI:

Get-Process | Invoke-PSRunSelector

It has a search bar where you can fuzzy-search processes with their name. By pressing Enter it returns the selected process object.

The powerful part is that you can perform various actions to the selected objects by piping them to other commands. An interactive process killer for example can be made by this one-linear:

Get-Process | Invoke-PSRunSelector | Stop-Process

This is pretty much the way how the interactive selector works. In the following sections, we’ll see how we can make it more useful by customizing the UI and behavior.

Customizing the UI

The objects that are passed to the selector can have two main UI parts, Name and Description. By default, the Name and Description properties of the input object are used but you can specify which properties to show.

Let's make a Script Launcher as an example. The command below lists ps1 script files and pipe them to the selector. The FileInfo object does not have a Description property, so we specify the Directory property instead:

Get-ChildItem *.ps1 -Recurse | Invoke-PSRunSelector -DescriptionProperty Directory

Another important UI element is the Preview window. The Preview window is a place where you can show detailed information about the focused object. Same as the -DescriptionProperty, you can specify which property is shown in the Preview with the -PreviewProperty parameter:

Get-ChildItem *.ps1 -Recurse | Invoke-PSRunSelector -PreviewProperty LastWriteTime -DescriptionProperty Directory

Since the Preview window is scrollable (with Shift+Up and Shift+Down keys), you would want to add more information than just a property. To build a custom string for the preview or other UI components, you can use -Expression parameter. It takes a ScriptBlock that returns a hashtable. The hashtable should include string values with Name, Description and Preview keys:

$originalOutputRendering = $PSStyle.OutputRendering
$PSStyle.OutputRendering = 'Ansi'

Get-ChildItem *.ps1 -Recurse | Invoke-PSRunSelector -Expression {@{
    Name = $_.Name
    Description = $_.Directory
    Preview = $_ | Format-Table | Out-String
}}

$PSStyle.OutputRendering = $originalOutputRendering

The above code sets the Preview string to the output of Format-Table. Out-String is necessary to get the formatted output as a string that was supposed to be rendered on the console. By setting $PSStyle.OutputRendering to Ansi, the code ensures that Out-String always outputs color escape sequences to the string.

Probably, it should be also useful if we can preview the script content in the Preview window. This is possible by just calling Get-Content but we use bat this time for a nice syntax highlighting:

Get-ChildItem *.ps1 -Recurse | Invoke-PSRunSelector -Expression {@{
    Name = $_.Name
    Description = $_.Directory
    # Preview is an array of strings.
    Preview = @($_ | Format-Table | Out-String) + (& bat --color=always $_.FullName)
}}

Although we got a nice preview, there is a performance issue because we read all script files for the preview before opening up the selector. There is a way to generate the preview string asynchronously, but you need a more advanced command to access that feature. Now, it’s time to use Invoke-PSRunSelectorCustom.

PreviewAsyncScript

Invoke-PSRunSelectorCustom takes SelectorEntry objects as input rather than objects themselves. It returns SelectorResult object that contains the selected entry. The previous code can be re-written like this:

$result = Get-ChildItem *.ps1 -Recurse | ForEach-Object {
    $entry = [PowerShellRun.SelectorEntry]::new()
    $entry.UserData = $_
    $entry.Name = $_.Name
    $entry.Description = $_.Directory
    $entry.Preview = @($_ | Format-Table | Out-String) + (& bat --color=always $_.FullName)
    $entry
} | Invoke-PSRunSelectorCustom
# The selected FileInfo is stored here.
$result.FocusedEntry.UserData

The SelectorEntry object has all the low level properties that can be used for customization. Instead of generating all the preview strings beforehand with the Preview property, you can use the PreviewAsyncScript and PreviewAsyncScriptArgumentList to asynchronously generate the preview strings:

$result = Get-ChildItem *.ps1 -Recurse | ForEach-Object {
    $entry = [PowerShellRun.SelectorEntry]::new()
    $entry.UserData = $_
    $entry.Name = $_.Name
    $entry.Description = $_.Directory
    $entry.PreviewAsyncScript = {
        param($fileInfo)
        @($fileInfo | Format-Table | Out-String) + (& bat --color=always $fileInfo.FullName)
    }
    $entry.PreviewAsyncScriptArgumentList = $_
    $entry
} | Invoke-PSRunSelectorCustom

Now the preview string is generated in a background thread when the entry is focused which improves the boot speed of the selector.

Custom Actions

We are making a script launcher, so after selecting a script we want to run the script:

$fileInfo = $result.FocusedEntry.UserData
if ($fileInfo) {
    & $fileInfo.FullName
}

SelectorResult object contains the selected SelectorEntry object in the FocusedEntry property. We can get the FileInfo object from the UserData property as we stored it there in the previous code. The null check is for the case when the user canceled the selector with the Escape key.

The Enter key is the default accept key, but you can add multiple keys as accept keys and perform different actions depending on the pressed key. Let’s assign Shift+Enter key to edit the script file. To specify additional keys per entry, you can set the ActionKeys property:

$result = Get-ChildItem *.ps1 -Recurse | ForEach-Object {
    $entry = [PowerShellRun.SelectorEntry]::new()
    $entry.UserData = $_
    $entry.Name = $_.Name
    $entry.Description = $_.Directory
    $entry.PreviewAsyncScript = {
        param($fileInfo)
        @($fileInfo | Format-Table | Out-String) + (& bat --color=always $fileInfo.FullName)
    }
    $entry.PreviewAsyncScriptArgumentList = $_
    $entry.ActionKeys = @('Enter:Run script', 'Shift+Enter:Edit script')
    $entry
} | Invoke-PSRunSelectorCustom

By pressing Ctrl+k, users can see what actions are available in the Action Window.

Now that Enter and Shift+Enter are both treated as an accept key, you need to branch the actions depending on the pressed key:

$fileInfo = $result.FocusedEntry.UserData
if ($fileInfo) {
    switch ($result.KeyCombination) {
        'Enter' {
            # Run the script.
            & $fileInfo.FullName
        }
        'Shift+Enter' {
            # Open the file in VSCode.
            code $fileInfo.FullName
        }
    }
}

Full Code

This is the full code for the script launcher we made in this post:

$originalOutputRendering = $PSStyle.OutputRendering
$PSStyle.OutputRendering = 'Ansi'

$result = Get-ChildItem *.ps1 -Recurse | ForEach-Object {
    $entry = [PowerShellRun.SelectorEntry]::new()
    $entry.UserData = $_
    $entry.Name = $_.Name
    $entry.Description = $_.Directory
    $entry.PreviewAsyncScript = {
        param($fileInfo)
        @($fileInfo | Format-Table | Out-String) + (& bat --color=always $fileInfo.FullName)
    }
    $entry.PreviewAsyncScriptArgumentList = $_
    $entry.ActionKeys = @('Enter:Run script', 'Shift+Enter:Edit script')
    $entry
} | Invoke-PSRunSelectorCustom

$PSStyle.OutputRendering = $originalOutputRendering

$fileInfo = $result.FocusedEntry.UserData
if ($fileInfo) {
    switch ($result.KeyCombination) {
        'Enter' {
            # Run the script.
            & $fileInfo.FullName
        }
        'Shift+Enter' {
            # Open the file in VSCode.
            code $fileInfo.FullName
        }
    }
}

Here is how it works. I typed “dock“ as a search word and hit Shift+Enter to edit the script:

Conclusion

Interactive Selectors are powerful tools that let you select objects interactively through their UI. PowerShellRun is one of those tools and gives you many customization options.

If you find something that is suitable for the selector interface, try making your ideal tool using PowerShellRun. It would make your workflow more efficient and fun to use.

References