Writing your own PowerShell Hosting App (part 6…the final episode)

Before we proceed with putting powershell objects in a treeview (which I promised last time), I need to explain some changes I have made to the code.

  • Refactoring the InvokeString functionality ouf of the menu item event
  • Merging the error stream into the output stream
  • Replacing the clear-host function with a custom cmdlet

First, we had been calling the invoke method in the OnClick event of the menu item. While that works fine as a proof-of-concept, we’re going to need that functionality elsewhere, so it’s a simple matter to extract the logic into a function as follows:

Sub RunToolStripMenuItem1Click(sender As Object, e As EventArgs)
  	InvokeString(txtScript.Text)
End Sub

Private Sub InvokeString(strScript As String)
	dim ps As powershell=PowerShell.Create()
        ps.Runspace=r
        ps.AddScript(strScript)
        ps.AddCommand("out-default")
        ps.Commands.Commands.Item(ps.Commands.Commands.Count-1).MergeUnclaimedPreviousCommandResults = PipelineResultTypes.Error + PipelineResultTypes.Output
        Dim output As Collection(Of psobject)
        output=ps.Invoke()

End Sub

In this new InvokeString method you see highlighted (if you allow javascript :-)) the line of code that merges the error stream into the output stream (so that errors we throw with our new cmdlets will show up in the console).  We’ll still need to update the our PSHostUserInterface class to handle the WriteError method, but that’s pretty easy (as are the debug, verbose, and warning methods):

Public Overloads Overrides Sub WriteErrorLine(value As String)
	MainForm.PowerShellOutput.AppendText("ERROR:"+value +vbcrlf)
End Sub

Public Overloads Overrides Sub WriteDebugLine(message As String)
	MainForm.PowerShellOutput.AppendText("DEBUG:"+message +vbcrlf)
End Sub

Public Overloads Overrides Sub WriteProgress(sourceId As Long, record As System.Management.Automation.ProgressRecord)
	Throw New NotImplementedException()
End Sub

Public Overloads Overrides Sub WriteVerboseLine(message As String)
	MainForm.PowerShellOutput.AppendText("VERBOSE:"+message +vbcrlf)
End Sub

Public Overloads Overrides Sub WriteWarningLine(message As String)
	MainForm.PowerShellOutput.AppendText("WARNING:"+message +vbcrlf)
End Sub

With that, we can see that the built-in clear-host isn’t going to work:

ERROR:Exception setting "CursorPosition": "The method or operation is not implemented
ERROR:."
ERROR:At line:8 char:16
ERROR:+ $Host.UI.RawUI. <<<< CursorPosition = $origin
ERROR:    + CategoryInfo          : InvalidOperation: (:) [], RuntimeException
ERROR:    + FullyQualifiedErrorId : PropertyAssignmentException
ERROR:
ERROR:Exception calling "SetBufferContents" with "2" argument(s): "The method or oper
ERROR:ation is not implemented."
ERROR:At line:9 char:33
ERROR:+ $Host.UI.RawUI.SetBufferContents <<<< ($rect, $space)
ERROR:    + CategoryInfo          : NotSpecified: (:) [], MethodInvocationException
ERROR:    + FullyQualifiedErrorId : DotNetMethodException
ERROR:

You can see that, by default, “clear-host” is a function that relies on the RawUI class in the host (using rectangles, and filling with spaces, it looks like).  We really don’t want that kind of access in our interface, so we’re going to replace this function with a cmdlet that simply clears the textbox.

That brings up another “benefit” of writing your own GUI host…the ability to implement cmdlets without writing SnapIns.  With 2.0, you can write advanced functions (and I encourage you to do that), but with 1.0 you didn’t have that option.  With your own host, you get to add cmdlets without the pain of a SnapIn installer.  The two things we need to do are:

  1. Create a cmdlet class to do the work
  2. Add the cmdlet to the runspace configuration

When we replace the clear-host function, we’re going to also want to remove the existing function, but that’s not typical.  Here’s the code:

First, the cmdlet class (I usually put all of the cmdlets in the same file, rather than having a single file for each class, but that’s just a preference):

Imports System.Management.Automation
Imports System.ComponentModel

 _
Public Class ClearHost
    Inherits Cmdlet

    Protected Overrides Sub EndProcessing()
        MainForm.PowerShellOutput.Clear
    End Sub
End Class

To add the cmdlet to the runspace (and remove the function), I added these lines after the r.Open() call:

InvokeString("remove-item function:clear-host")
r.RunspaceConfiguration.Cmdlets.Prepend(New CmdletConfigurationEntry("clear-host",GetType(ClearHost),Nothing))
r.RunspaceConfiguration.Cmdlets.Update()

Now, finally, on to the promised treeview manipulation.  I want the cmdlet to be fairly simple, allowing you to specify the name of the label of the new node, and optionally the label of the parent node and an object to attach to the node (we’ll put it in the tag property of the treenode).  We’ll also need to expose the treeview control in a shared member of the form (since the cmdlet doesn’t have a reference to the specific window we instantiate).

First, here’s the cmdlet.  I’ve tried to make the code as simple as possible, so there are no tricks involved.

 _
Public Class NewTreeNode
	Inherits Cmdlet

	private _nodename as String=""
	private _parentnodename as String=""
	private _object as PSObject=nothing
 _
    Public Property NodeName() As String
        Get
            Return _nodename
        End Get

        Set(ByVal value As String)
            _nodename = value
        End Set

    End Property
 _
    Public Property ParentNodeName() As String
        Get
            Return _parentnodename
        End Get

        Set(ByVal value As String)
            _parentnodename = value
        End Set

    End Property
 _
    Public Property PSObject() As PSObject
        Get
            Return _object
        End Get

        Set(ByVal value As PSObject)
            _object = value
        End Set

    End Property

    Protected Overloads Overrides Sub EndProcessing()
		MyBase.EndProcessing()
		Dim _node As TreeNode
		Dim _parent As TreeNode
		_parent=PWBUIHandling.FindNodeInTree(_parentnodename,mainform.Tree.Nodes)
		If _parent is nothing then
			_node=MainForm.Tree.Nodes.Add(_nodename,_nodename)
		Else
			_node=_parent.Nodes.Add(_nodename,_nodename)
		End If
		_node.Tag=_object
    End Sub
End Class

In the form, we’ll need to add a treeview (I also added a second splitter to help organize the UI, but that’s obviously not necessary).  Adding the shared property,setting it, and adding the cmdlet to the runspace complete the changes:

Public Partial Class MainForm

Public Shared PowerShellOutput As textbox
public shared Tree as TreeView
private host as new PowerShellWorkBenchHost
private r As Runspace=RunspaceFactory.CreateRunspace(host)
	Public Sub New()
		' The Me.InitializeComponent call is required for Windows Forms designer support.
		Me.InitializeComponent()

		PowerShellOutput=txtOutput
		Tree=treeView1
       	r.ThreadOptions=PSThreadOptions.UseCurrentThread
        r.Open()
        InvokeString("remove-item function:clear-host")
      	r.RunspaceConfiguration.Cmdlets.Prepend(New CmdletConfigurationEntry("clear-host",GetType(ClearHost),Nothing))
      	r.RunspaceConfiguration.Cmdlets.Append(New CmdletConfigurationEntry("new-treenode",GetType(NewTreeNode),Nothing))
      	r.RunspaceConfiguration.Cmdlets.Update()

   End Sub

With that, let’s see how it works:

screenshot00052009113022_11_42_thumb.jpg

Obviously, I haven’t built an application that’s ready for use, but I think it is a good example of how you can use the PowerShell APIs to create a scriptable environment that you can customize.  And the fact that the code written to make it happen is less than 200 lines is a testament to the useful nature of the API (actual hand-coded lines, that is, there are about 400 lines in the whole project).

What’s next?  I think I’ll stop on the tutorial and segue into the codeplex project I’m starting (it should be live in the next week or 2).  In it, you should find things like

  • Syntax Highlighting (thanks to AvalonEdit)
  • Advanced docking interface (thanks to AvalonDock)
  • Tab Expansion
  • Custom pop-up menus for UI objects (like the nodes in the tree, for example)
  • Whatever else I (or anyone who wants to contribute) think of

-Mike

P.S.  I just realized that I forgot to include the FindNodeInTree function that the cmdlet called.  I hate that the treeview class doesn’t include a Find method.  Here’s the code:

	Function FindNodeInTree(nodename As String, nodes As TreeNodeCollection) as TreeNode
		dim rtn as TreeNode =nothing
		If nodes.ContainsKey(nodename) Then
			Return nodes(nodename)
		Else
			For Each node As treenode In nodes
				rtn=FindNodeInTree(nodename,node.Nodes)
				If rtn IsNot Nothing Then
					return rtn
				End If
			Next

		End If
		return rtn
	End Function