PoshInteractive.psm1
function Invoke-InteractivePipeline { <# .SYNOPSIS This PowerShell function provides a set of handy pipeline tools for interactive sessions. .DESCRIPTION This tool can do stuff like an on demand progress indicator, pausing pipeline, stepping up and down the pipeline, and exiting pipeline cleanly .PARAMETER InputObject The pipeline object to send on downstream .PARAMETER TotalCount The number of total items to be iterated over. This can be used to calculate SecondsLeft and PercentComplete for Write-Progress .PARAMETER Pause Begin by starting at the command screen .PARAMETER ShowProgress Enable updating via Write-Progress for each object that is iterated over .PARAMETER StatusMessage A custom scriptblock to run when showing progress. Some parameters are passed into the scriptblock via $args variable. Available parameters in $args are: Existing: The default status message InputObject: The current item loaded in the pipeline TotalCount: The number of expected objects down the pipeline .EXAMPLE 1..100 | iip | %{ sleep -mil 100 } # Press P or ? to pause execution and enter command mode .EXAMPLE 1..100 | iip -ShowProgress -TotalCount 100 | %{ sleep -mil 100 } # Tells the command how many iterations to expect because that is an unknown in pipeline exection .EXAMPLE Interactive (1..100) -ShowProgress | %{ sleep -mil 100 } # Same as prior example, but can determine object count due to no pipeline .EXAMPLE Interactive (1..100) -ShowProgress -StatusMessage {"{0} - {1}" -f $args.Existing,$args.InputObject} | %{ sleep -mil 100 } Append the InputObject onto the Write-Progress Status parameter. .EXAMPLE Interactive (1..100) -ShowProgress -StatusMessage {"{0}/{1} - {2}" -f $args.CurrentCount,$args.TotalCount,$args.InputObject} | %{ sleep -mil 100 } Override the Write-Progress Status parameter with a custom output text .EXAMPLE Interactive (1..100) -ShowProgress | %{ sleep -mil 50 ;$_/2} | iip | measure -Average # Press P to pause, then C to show current object, then <Right> to move to the other Invoke-InteractivePipeline (Interactive/iip), then press C to show current object. # Notice that if you press N and then C, you are still only seeing the objects as they pass the second instance of iip in the pipeline. .NOTES This function unrolls any input arrays! You have been warned. #> [Alias('iip', 'interactive')] # [CmdletBinding(PositionalBinding = $false)] [CmdletBinding()] param( [Parameter(ValueFromPipeline)] $InputObject, # [Parameter(Position = 0)] [ValidateRange(1, [int]::MaxValue)] [int] $TotalCount, [switch] $Pause, [switch] $ShowProgress, [ScriptBlock]$StatusMessage ) begin { function Write-ProgressReport { $write_progress_splat.Status = "Currently on item #$current" if ($TotalCount) { $write_progress_splat.Status += " (of $TotalCount)" $write_progress_splat.PercentComplete = $current / $TotalCount * 100 if ($write_progress_splat.PercentComplete -gt 100) { $write_progress_splat.PercentComplete = 100 $write_progress_splat.SecondsRemaining = 0 } else { $write_progress_splat.SecondsRemaining = ($timer.Elapsed / ($current / $TotalCount) - $timer.Elapsed ).TotalSeconds $timeleft = [timespan]::new(0, 0, $write_progress_splat.SecondsRemaining) $time_string = "" if ($timeleft.Days) { $time_string += "{0}d" -f $timeleft.Days } if ($timeleft.Hours) { $time_string += "{0}h" -f $timeleft.Hours } if ($timeleft.Minutes) { $time_string += "{0}m" -f $timeleft.Minutes } if ($timeleft.Seconds) { $time_string += "{0}s" -f $timeleft.Seconds } $write_progress_splat.Status += " (remaining:$time_string)" } } if ($StatusMessage) { $write_progress_splat.Status = ( Invoke-Command -ScriptBlock $StatusMessage -ArgumentList @{ Existing = $write_progress_splat.Status InputObject = $item CurrentCount = $current TotalCount = $TotalCount } ) } Write-Progress @write_progress_splat } if ($InputObject) { $TotalCount = $InputObject.count } $current = 0 $write_progress_splat = @{ Activity = "Processing pipeline objects" } if (-not $script:activeID) { $script:activeID = 1 } if (-not $script:scheduler) { $script:scheduler = @{} } # Use a hashtable with one key per command to ensure stuff is reset for each command $HistoryId = $MyInvocation.HistoryId $script:scheduler.$HistoryId++ $myID = $script:scheduler.$HistoryId # First occurance of this in a pipeline should clear any leftover pauses if ($myID -eq 1) { $script:Paused = $false } # If this occurance should start paused, set to paused if ($Pause) { $script:Paused = $true } $timer = [System.Diagnostics.Stopwatch]::StartNew() } process { foreach ($item in $InputObject) { $current++ if ([Console]::KeyAvailable -or $script:Paused) { $timer.Stop() if ($myID -eq $script:activeID) { . Invoke-InteractiveCommand while ([Console]::KeyAvailable) { [void][Console]::ReadKey($true) } } $timer.Start() } elseif ($ShowProgress) { Write-ProgressReport } $item } } end { if ($script:scheduler.$HistoryId -lt $script:activeID) { $script:activeID = 1 } } } function Assert-CommandStopperInitialized { [CmdletBinding()] param() end { if ('UtilityProfile.CommandStopper' -as [type]) { return } Add-Type -TypeDefinition ' using System; using System.ComponentModel; using System.Linq.Expressions; using System.Management.Automation; using System.Management.Automation.Internal; using System.Reflection; namespace UtilityProfile { [EditorBrowsable(EditorBrowsableState.Never)] [Cmdlet(VerbsLifecycle.Stop, "UpstreamCommand")] public class CommandStopper : PSCmdlet { private static readonly Func<PSCmdlet, Exception> s_creator; static CommandStopper() { ParameterExpression cmdlet = Expression.Parameter(typeof(PSCmdlet), "cmdlet"); s_creator = Expression.Lambda<Func<PSCmdlet, Exception>>( Expression.New( typeof(PSObject).Assembly .GetType("System.Management.Automation.StopUpstreamCommandsException") .GetConstructor( BindingFlags.Public | BindingFlags.Instance, null, new Type[] { typeof(InternalCommand) }, null), cmdlet), "NewStopUpstreamCommandsException", new ParameterExpression[] { cmdlet }) .Compile(); } [Parameter(Position = 0, Mandatory = true)] [ValidateNotNull] public Exception Exception { get; set; } [Hidden, EditorBrowsable(EditorBrowsableState.Never)] public static void Stop(PSCmdlet cmdlet) { var exception = s_creator(cmdlet); cmdlet.SessionState.PSVariable.Set("__exceptionToThrow", exception); var variable = GetOrCreateVariable(cmdlet, "__exceptionToThrow"); object oldValue = variable.Value; try { variable.Value = exception; ScriptBlock.Create("& $ExecutionContext.InvokeCommand.GetCmdletByTypeName([UtilityProfile.CommandStopper]) $__exceptionToThrow") .GetSteppablePipeline(CommandOrigin.Internal) .Begin(false); } finally { variable.Value = oldValue; } } private static PSVariable GetOrCreateVariable(PSCmdlet cmdlet, string name) { PSVariable result = cmdlet.SessionState.PSVariable.Get(name); if (result != null) { return result; } result = new PSVariable(name, null); cmdlet.SessionState.PSVariable.Set(result); return result; } protected override void BeginProcessing() { throw Exception; } } }' } } function Invoke-InteractiveCommand { # This function gets run in the local scope via dot source: # It edits variables directly. do { if (-not [Console]::KeyAvailable) { Write-Host "Processing paused by function. Press any command key (?/ /s/#/p/e/,/./n/c/j/d/other):" } $keyInfo = [Console]::ReadKey($true) if ($keyInfo.KeyChar -eq '?') { @( "q - Quit" "<space> - Write-Progress" "s - Toggle Write-Progress" "# - Enter total count for Write-Progress" "p - Pause processing" "e - Enter nested prompt" "<Right>/. - Move active Interactive down the pipeline (to the right)" "<Left>/, - Move active Interactive up the pipeline (to the left)" "<Down>/n - Next item" "c - Current item display" "j - Current item display (JSON)" "d - Debug info" "? - This help text" "Anything else - continue processing" ) | Out-String | Write-Host $script:Paused = $true } elseif ($keyInfo.KeyChar -eq 'q') { Assert-CommandStopperInitialized [UtilityProfile.CommandStopper]::Stop($PSCmdlet) } elseif ($keyInfo.KeyChar -eq ' ') { $script:Paused = $false Write-ProgressReport } elseif ($keyInfo.KeyChar -eq 's') { if ($ShowProgress) { $ShowProgress = $false } else { $ShowProgress = $true } } elseif ($keyInfo.KeyChar -eq '#') { [int]$TotalCount = Read-Host "TotalCount" } elseif ($keyInfo.KeyChar -eq 'p') { Write-Host "Processing paused. Press any command key to run that command or any other key to continue..." # [void][Console]::ReadKey($true) $script:Paused = $true } elseif ($keyInfo.KeyChar -eq 'e') { Write-Host "use `$item, `$PSItem, or `$Inputobject to access current item" $host.EnterNestedPrompt() } elseif ($keyInfo.Key -in 37, 'OemComma') { $script:Paused = $false $script:continuePause = $true if ($script:activeID -gt 1) { $script:activeID-- } } elseif ($keyInfo.Key -in 39, 'OemPeriod') { $script:Paused = $false $script:continuePause = $true if ($script:scheduler.$HistoryId -gt $script:activeID) { $script:activeID++ } } elseif ($keyInfo.Key -in 40, 'n') { $script:Paused = $false $script:continuePause = $true } elseif ($keyInfo.KeyChar -eq 'c') { $InputObject | Out-String | Write-Host } elseif ($keyInfo.KeyChar -eq 'j') { if ($ENV:INTERACTIVE_JSON_DEPTH) { $jsonDepth = $ENV:INTERACTIVE_JSON_DEPTH } else { $jsonDepth = 2 } $InputObject | ConvertTo-Json -Depth $jsonDepth | Out-String | Write-Host } elseif ($keyInfo.KeyChar -eq 'd') { "Item number: {0}, value: {1}, interactives count: {2}, active invocation: {3}" -f @( $current $item $script:scheduler.$HistoryId $script:activeID ) | Write-Host } else { $script:Paused = $false } } while ($script:Paused) # Enable skipping out of current loop but stop next active occurence. if ($script:continuePause) { $script:Paused = $true $script:continuePause = $false } } |