public/session/Invoke-UiAsync.ps1
|
function Invoke-UiAsync { <# .SYNOPSIS Runs a scriptblock in the background without freezing the UI. .DESCRIPTION Uses the AsyncExecutor RunspacePool for fast, efficient background execution. Automatically captures variables and functions from the caller's scope. .PARAMETER ScriptBlock Code to run in background. .PARAMETER OnComplete Code to run when done. Receives the result as parameter. .PARAMETER OnError Code to run on error. Receives the error as parameter. .PARAMETER Arguments Arguments to pass to the scriptblock (legacy compatibility). .PARAMETER Variables Hashtable of variables to pass to the background runspace. .PARAMETER Capture Variable names to capture from the runspace after execution completes. Captured variables are stored in the session and available to subsequent async calls, and persist in global scope after the window closes. .PARAMETER AutoCapture Automatically capture variables used in ScriptBlock from caller scope. Default: $true .PARAMETER NoAutoCapture Disables automatic variable capture from caller scope. Use when you want full control over what's passed in. .EXAMPLE Invoke-UiAsync -ScriptBlock { Get-ChildItem C:\ -Recurse } -OnComplete { param($result) Write-Host "Found $($result.Count) items" } .EXAMPLE $path = "C:\Temp" Invoke-UiAsync -ScriptBlock { Get-ChildItem $path # $path is auto-captured } #> [CmdletBinding()] param( [Parameter(Mandatory)] [scriptblock]$ScriptBlock, [scriptblock]$OnComplete, [scriptblock]$OnError, [object[]]$Arguments, [hashtable]$Variables, [string[]]$Capture, [switch]$NoAutoCapture ) if ($Capture) { foreach ($varName in $Capture) { if (![PsUi.Constants]::IsValidIdentifier($varName)) { throw "Invalid variable name for -Capture: '$varName'. Names must start with a letter or underscore and contain only letters, numbers, underscores, or hyphens." } } } Write-Debug "Starting async execution, AutoCapture=$(!$NoAutoCapture)" $executor = [PsUi.AsyncExecutor]::new() # Set dispatcher for proper UI thread marshaling if ([System.Windows.Application]::Current) { $executor.UiDispatcher = [System.Windows.Application]::Current.Dispatcher } # Store executor in session for Stop-UiAsync cancellation $execSession = [PsUi.SessionManager]::Current if ($execSession) { $execSession.ActiveExecutor = $executor } $varsToInject = @{} # Auto-capture variables from ScriptBlock using AST (same as New-UiButton) if (!$NoAutoCapture) { $ast = $ScriptBlock.Ast $builtinVars = @( '_', 'PSItem', 'this', 'args', 'input', 'PSCmdlet', 'PSBoundParameters', 'MyInvocation', 'ExecutionContext', 'null', 'true', 'false', 'PSScriptRoot', 'PSCommandPath', 'PID', 'Host', 'PSVersionTable', 'Error', 'StackTrace', 'HOME', 'PROFILE', 'PSCulture', 'PSUICulture', 'ShellId', 'NestedPromptLevel', 'state', 'session', 'executor', 'varsToInject', 'functionsToInject' ) $referencedVars = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true) | ForEach-Object { $_.VariablePath.UserPath } | Select-Object -Unique foreach ($varName in $referencedVars) { if ($varName -notin $builtinVars) { # Dynamically walk up the scope chain until we hit Global # This handles deeply nested modules/jobs where scope > 10 $scopeIndex = 1 $foundValue = $false while (!$foundValue) { try { $val = Get-Variable -Name $varName -Scope $scopeIndex -ValueOnly -ErrorAction Stop $varsToInject[$varName] = $val $foundValue = $true } catch [System.Management.Automation.ItemNotFoundException] { # Variable not found at this scope, try next $scopeIndex++ } catch [System.ArgumentOutOfRangeException] { # We've gone past Global scope, variable doesn't exist break } catch { # Other error (e.g., scope doesn't exist), stop searching break } } } } } if ($Variables) { Write-Debug "Adding $($Variables.Count) explicit variable(s)" foreach ($key in $Variables.Keys) { $varsToInject[$key] = $Variables[$key] } } # Add Arguments as $args if provided (legacy compatibility) if ($Arguments) { $varsToInject['args'] = $Arguments } $functionsToInject = @{} if (!$NoAutoCapture) { $commandAsts = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true) $calledCommands = $commandAsts | ForEach-Object { $cmdElement = $_.CommandElements[0] if ($cmdElement -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $cmdElement.Value } } | Select-Object -Unique foreach ($cmdName in $calledCommands) { if (!$cmdName) { continue } $cmdInfo = Get-Command -Name $cmdName -ErrorAction SilentlyContinue if ($cmdInfo -and $cmdInfo.CommandType -eq 'Function') { $funcDef = $cmdInfo.Definition if ($funcDef -and !$functionsToInject.ContainsKey($cmdName)) { $functionsToInject[$cmdName] = $funcDef } } } } Write-Debug "Injecting $($varsToInject.Count) variable(s), $($functionsToInject.Count) function(s)" # Capture session ID so we can restore it on the UI thread when OnComplete fires $capturedSessionId = [PsUi.SessionManager]::CurrentSessionId $state = [hashtable]::Synchronized(@{ Results = [System.Collections.Generic.List[object]]::new() Errors = [System.Collections.Generic.List[object]]::new() OnComplete = $OnComplete OnError = $OnError Executor = $executor SessionId = $capturedSessionId }) $executor.add_OnPipelineOutput({ param($obj) if ($null -ne $obj) { [void]$state.Results.Add($obj) } }.GetNewClosure()) $executor.add_OnError({ param($errorRecord) # $errorRecord is now PSErrorRecord - format nicely for collection if ($null -ne $errorRecord) { # Use the ToDetailedString method if available, otherwise build our own $formatted = if ($errorRecord.PSObject.Methods.Match('ToDetailedString')) { $errorRecord.ToDetailedString() } else { # Fallback for backwards compatibility $details = [System.Collections.Generic.List[string]]::new() $details.Add("ERROR: $($errorRecord.Message)") if ($errorRecord.LineNumber -gt 0) { $details.Add("Line: $($errorRecord.LineNumber)") } if ($errorRecord.ScriptName) { $details.Add("Script: $($errorRecord.ScriptName)") } if ($errorRecord.Line) { $details.Add("Code: $($errorRecord.Line)") } if ($errorRecord.ScriptStackTrace) { $details.Add("`nStack Trace:`n$($errorRecord.ScriptStackTrace)") } $details -join "`n" } [void]$state.Errors.Add($formatted) } }.GetNewClosure()) # Completion callback - runs on UI thread via AsyncExecutor's MarshalToUi $executor.add_OnComplete({ try { # Restore session context on UI thread so Set-UiValue and other functions work if ($state.SessionId -ne [Guid]::Empty) { [PsUi.SessionManager]::SetCurrentSession($state.SessionId) } if ($state.Errors.Count -gt 0 -and $state.OnError) { & $state.OnError ($state.Errors -join "`n`n") } elseif ($state.OnComplete) { if ($state.Results.Count -eq 0) { & $state.OnComplete $null } elseif ($state.Results.Count -eq 1) { & $state.OnComplete $state.Results[0] } else { & $state.OnComplete @($state.Results) } } } catch { Write-Warning "Invoke-UiAsync OnComplete error: $_" } finally { if ($state.Executor) { $state.Executor.Dispose() } } }.GetNewClosure()) if ($Capture) { $executor.CaptureVariables = [string[]]$Capture } Write-Debug "Dispatching to AsyncExecutor" $executor.ExecuteAsync($ScriptBlock, $null, $varsToInject, $functionsToInject, $null) return [PSCustomObject]@{ Executor = $executor Cancel = { $executor.Cancel() $executor.Dispose() }.GetNewClosure() } } |