private/output/Invoke-OnCompleteHandler.ps1
|
function Invoke-OnCompleteHandler { <# .SYNOPSIS Finalizes async output - builds Results tab, updates console, hides loading spinner. #> # Does a lot. Wants breaking into smaller pieces. [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Context ) # Extract all context values for readability $Executor = $Context.Executor $state = $Context.State $outputData = $Context.OutputData $outputDataByType = $Context.OutputDataByType $consoleColorMap = $Context.ConsoleColorMap $appendConsoleText = $Context.AppendConsoleText $appendState = $Context.AppendState $drainConsole = $Context.DrainConsole $consoleParagraph = $Context.ConsoleParagraph $consoleTextBox = $Context.ConsoleTextBox $consoleTab = $Context.ConsoleTab $autoScrollCheckbox = $Context.AutoScrollCheckbox $errorsTabState = $Context.ErrorsTabState $ensureErrorsTab = $Context.EnsureErrorsTab $warningsTabState = $Context.WarningsTabState $ensureWarningsTab = $Context.EnsureWarningsTab $warningCount = $Context.WarningCount $tabControl = $Context.TabControl $tabNotifications = $Context.TabNotifications $window = $Context.Window $hideLoading = $Context.HideLoading $loadingPanel = $Context.LoadingPanel $loadingSpinner = $Context.LoadingSpinner $loadingStack = $Context.LoadingStack $loadingLabel = $Context.LoadingLabel $progressBar = $Context.ProgressBar $progressPanel = $Context.ProgressPanel $headerTitle = $Context.HeaderTitle $Title = $Context.Title $HideUntilContent = $Context.HideUntilContent $ParentWindow = $Context.ParentWindow $statusSpinner = $Context.StatusSpinner $statusSuccess = $Context.StatusSuccess $statusWarning = $Context.StatusWarning $statusIndicator = $Context.StatusIndicator $colors = $Context.Colors $ResultActions = $Context.ResultActions $SingleSelect = $Context.SingleSelect $varValues = $Context.VarValues $funcDefs = $Context.FuncDefs $capturedModules = $Context.CapturedModules $defaultProgressUI = $Context.DefaultProgressUI try { if ($state.DebugEnabled) { $eCount = [int]$errorsTabState.TotalErrors [Console]::WriteLine("[DEBUG] OnComplete fired - Results: $($outputData.Count), Types: $($outputDataByType.Count), Errors: $eCount") } if ($state.IsCancelled) { if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] OnComplete cancelled - window was closed") } return } # Stop queue polling timer and drain any remaining items if ($state.HostQueueTimer.IsEnabled) { $state.HostQueueTimer.Stop() } # Drain remaining pipeline queue items (critical for final batch) while ($Executor.PipelineQueueCount -gt 0) { $pipelineItems = $Executor.DrainPipelineQueue(500) if ($null -eq $pipelineItems -or $pipelineItems.Count -eq 0) { break } foreach ($item in $pipelineItems) { if ($null -eq $item) { continue } # Route ErrorRecords to Errors tab instead of Results if ($item -is [System.Management.Automation.ErrorRecord]) { & $ensureErrorsTab $displayRecord = New-ErrorDisplayRecord -ErrorRecord $item [void]$errorsTabState.List.Add($displayRecord) $errorsTabState.TotalErrors = [int]$errorsTabState.TotalErrors + 1 if ($errorsTabState.Tab.Visibility -eq 'Collapsed') { $errorsTabState.Tab.Visibility = 'Visible' } continue } $displayName = Get-CleanTypeName -Item $item if (!$outputDataByType.Contains($displayName)) { $outputDataByType[$displayName] = [System.Collections.Generic.List[object]]::new() } [void]$outputDataByType[$displayName].Add($item) [void]$outputData.Add([psobject]$item) } } # Errors picked up by this never went through the tick handler, so the header count has to refresh here too. if ($errorsTabState.Tab -and [int]$errorsTabState.TotalErrors -gt 0) { $errorsTabState.Tab.Header = "Errors ($([int]$errorsTabState.TotalErrors))" } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Final drain - Results: $($outputData.Count), Types: $($outputDataByType.Count)") } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Host queue remaining: $($Executor.HostQueueCount) items") } # Drain remaining host queue items $hostRecordsDrained = 0 while ($Executor.HostQueueCount -gt 0) { $drained = & $drainConsole 500 -LeaveTabs if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Drained " + $drained + " host records") } $hostRecordsDrained += $drained } # Make Console tab visible if any host records drained if ($hostRecordsDrained -gt 0) { if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Making Console tab visible after draining " + $hostRecordsDrained + " records") } if ($consoleTab.Visibility -ne 'Visible') { $consoleTab.Visibility = 'Visible' } $tabNotifications.Console.UnreadCount += $hostRecordsDrained $consoleTab.Header = "Console (+$($tabNotifications.Console.UnreadCount))" } # Park the scroll where requested, top for ScrollToTop and bottom when autoscroll is on. if ($Context.ScrollToTop) { $consoleTextBox.ScrollToHome() } elseif ($autoScrollCheckbox -and $autoScrollCheckbox.IsChecked) { $consoleTextBox.ScrollToEnd() } # Hide loading spinner on completion regardless & $hideLoading # If in HideUntilContent mode, check if there's any actual content if ($HideUntilContent) { # If window was already revealed by the final queue drain, don't close it if ($state.WindowRevealed) { if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] OnComplete: Window already revealed, skipping close check") } } else { $hasConsoleOutput = $consoleParagraph.Inlines.Count -gt 0 $hasErrors = [int]$errorsTabState.TotalErrors -gt 0 $hasWarnings = $warningCount.Value -gt 0 $hasResults = $outputData.Count -gt 0 if (!$hasConsoleOutput -and !$hasErrors -and !$hasWarnings -and !$hasResults) { Write-Verbose "No content in any tab, closing silently" # Activate parent window before closing to prevent console stealing focus if ($ParentWindow) { $ParentWindow.Activate() } $window.Close() return } # Content exists but the window was never revealed, so reveal it now $state.WindowRevealed = $true $window.Visibility = 'Visible' } } $progressBar.IsIndeterminate = $false $progressBar.Value = 100 $headerTitle.Text = "$Title - Complete" $progressPanel.Visibility = 'Collapsed' # Stop the spinner and show success or warning. $statusSpinner.Visibility = 'Collapsed' $hasErrors = [int]$errorsTabState.TotalErrors -gt 0 $hasWarnings = $warningCount.Value -gt 0 if ($hasErrors -or $hasWarnings) { $statusWarning.Visibility = 'Visible' $statusIndicator.ToolTip = "Complete with errors/warnings" # Update taskbar overlay to warning/error icon try { $errorOverlay = New-TaskbarOverlayIcon -GlyphChar ([PsUi.ModuleContext]::GetIcon('Alert')) -Color '#FFA500' -BackgroundColor '#FFFFFF' [PsUi.WindowManager]::SetTaskbarOverlay($window, $errorOverlay, 'Completed with warnings') } catch { Write-Debug "Suppressed taskbar warning overlay error: $_" } } else { $statusSuccess.Visibility = 'Visible' $statusIndicator.ToolTip = "Complete" # Update taskbar overlay to success checkmark try { $successOverlay = New-TaskbarOverlayIcon -GlyphChar ([PsUi.ModuleContext]::GetIcon('Accept')) -Color '#107C10' -BackgroundColor '#FFFFFF' [PsUi.WindowManager]::SetTaskbarOverlay($window, $successOverlay, 'Completed successfully') } catch { Write-Debug "Suppressed taskbar success overlay error: $_" } } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] About to check outputData.Count = " + $outputData.Count) } if ($outputData.Count -gt 0) { if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Creating Results tab with " + $outputData.Count + " items") } # Convert to safe data array $dataArray = ConvertTo-SafeDataArray -DataArray @($outputData.ToArray()) # A single hashtable is processed as a collection, otherwise it'd lack expandable values and the filter $presenter = Get-OutputPresenter -Data $dataArray # Create Results tab with toolbar using helper $toolbarResult = New-ResultsToolbar -Colors $colors -ResultActions $ResultActions $resultsTab = $toolbarResult.Tab $resultsPanel = $toolbarResult.Panel $toolbar = $toolbarResult.Toolbar $toolbar2 = $toolbarResult.Toolbar2 $rightToolbar = $toolbarResult.RightToolbar $filterPanel = $toolbarResult.FilterPanel $resultsBorder = $toolbarResult.ResultsBorder $exportButton = $toolbarResult.ExportButton $copyButton = $toolbarResult.CopyButton $dropdownPopup = $toolbarResult.DropdownPopup $actionDropdownMenuStack = $toolbarResult.ActionDropdownMenuStack $resultsTab.Header = "Results ($($outputData.Count))" # Display results if ($presenter -eq 'Collection') { $singleSelectParam = if ($SingleSelect) { @{ SingleSelect = $true } } else { @{} } $dataGrid = New-StyledDataGrid @singleSelectParam # Multi-type view with sub-TabControl if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Checking multi-type - outputDataByType.Count = " + $outputDataByType.Count) } foreach ($typeKey in $outputDataByType.Keys) { if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Type key: " + $typeKey) } } # Limit to 10 tabs, with the overflow merged into an "Other" bucket $maxTypeTabs = 10 $sortedKeys = $outputDataByType.Keys | Sort-Object { $outputDataByType[$_].Count } -Descending $typesToRender = @($sortedKeys | Select-Object -First $maxTypeTabs) $overflowTypes = @($sortedKeys | Select-Object -Skip $maxTypeTabs) if ($overflowTypes.Count -gt 0) { $otherBucket = [System.Collections.Generic.List[object]]::new() foreach ($overflowKey in $overflowTypes) { foreach ($item in $outputDataByType[$overflowKey]) { $otherBucket.Add($item) } } $outputDataByType['Other'] = $otherBucket $typesToRender += 'Other' if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Merged " + $overflowTypes.Count + " overflow types into 'Other' bucket (" + $otherBucket.Count + " items)") } } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Creating type-bucketed sub-TabControl with " + $typesToRender.Count + " tab(s)") } $subTabControl = [System.Windows.Controls.TabControl]::new() $subTabControl.Background = ConvertTo-UiBrush $colors.WindowBg $subTabControl.BorderThickness = [System.Windows.Thickness]::new(0) $subTabControl.Padding = [System.Windows.Thickness]::new(0) $firstDataGrid = $null foreach ($typeName in $typesToRender) { $groupItems = $outputDataByType[$typeName] if ($groupItems.Count -eq 0) { continue } $subTab = [System.Windows.Controls.TabItem]::new() $subTab.Header = "$typeName ($($groupItems.Count))" Set-TabItemStyle -TabItem $subTab $firstItem = $groupItems[0] $isTextType = ($typeName -eq 'String') -or ($firstItem -is [string]) -or (($firstItem -is [ValueType]) -and !($firstItem -is [System.Collections.DictionaryEntry])) if ($isTextType) { $richTextBox = New-TextDisplayRichTextBox -Colors $colors -Lines $groupItems $subTab.Content = $richTextBox $subTab.Tag = 'TextType' [void]$subTabControl.Items.Add($subTab) if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Added TEXT sub-tab for type '" + $typeName + "' with " + $groupItems.Count + " lines") } continue } if ($firstItem -is [System.Collections.DictionaryEntry]) { $result = New-DictionarySubTab -GroupItems $groupItems -TypeName $typeName -IsDictionaryEntry -SingleSelect:$SingleSelect [void]$subTabControl.Items.Add($result.Tab) if ($null -eq $firstDataGrid) { $firstDataGrid = $result.DataGrid } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Added DICTIONARYENTRY sub-tab for type '" + $typeName + "'") } continue } if ($firstItem -is [System.Collections.IDictionary]) { $result = New-DictionarySubTab -GroupItems $groupItems -TypeName $typeName -SingleSelect:$SingleSelect [void]$subTabControl.Items.Add($result.Tab) if ($null -eq $firstDataGrid) { $firstDataGrid = $result.DataGrid } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Added DICTIONARY sub-tab for type '" + $typeName + "'") } continue } # Create object DataGrid sub-tab using helper $includeStatus = ($ResultActions -and $ResultActions.Count -gt 0) $subTabParams = @{ GroupItems = $groupItems TypeName = $typeName SubTabControl = $subTabControl SingleSelect = $SingleSelect IncludeActionStatus = $includeStatus } $result = New-ObjectSubTab @subTabParams [void]$subTabControl.Items.Add($result.Tab) if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Added sub-tab for type '" + $typeName + "' with " + $groupItems.Count + " items") } if ($null -eq $firstDataGrid) { $firstDataGrid = $result.DataGrid } } if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Multi-type complete - subTabControl has " + $subTabControl.Items.Count + " tabs") } $resultsBorder.Child = $subTabControl $dataGrid = $firstDataGrid # Add SelectionChanged handler to filter ResultAction buttons by ObjectType $capturedToolbar = $toolbar $capturedDropdownMenuStack = $actionDropdownMenuStack $subTabControl.Add_SelectionChanged({ param($sender, $eventArgs) # Ignore SelectionChanged events that walk up from child controls (e.g. DataGrid) # Only process events that originated from the TabControl itself if ($eventArgs.OriginalSource -ne $sender -and $eventArgs.OriginalSource -isnot [System.Windows.Controls.TabItem]) { return } $selectedTab = $sender.SelectedItem if (!$selectedTab) { return } # Clear filter on the previously selected tab's DataGrid if ($eventArgs.RemovedItems.Count -gt 0) { $previousTab = $eventArgs.RemovedItems[0] $prevGrid = Get-UiSubTabGrid -Tab $previousTab if ($prevGrid) { $view = [System.Windows.Data.CollectionViewSource]::GetDefaultView($prevGrid.ItemsSource) if ($view -and $view.Filter) { $view.Filter = $null $view.Refresh() } } } # Update filter box enabled state based on tab's indexed status $filterBox = $sender.Tag if ($filterBox) { if (![string]::IsNullOrEmpty($filterBox.Text)) { $filterBox.Text = '' } elseif ($filterBox.Tag.RunFilter) { if ($selectedTab.Tag -eq 'TextType') { & $filterBox.Tag.RunFilter $filterBox } else { $incomingGrid = Get-UiSubTabGrid -Tab $selectedTab $incomingTag = if ($incomingGrid) { $incomingGrid.Tag } else { $null } if ($incomingTag -is [hashtable] -and $null -ne $incomingTag.Observable -and $null -ne $incomingTag.UnfilteredItems -and $incomingTag.Observable.Count -ne $incomingTag.UnfilteredItems.Count) { & $filterBox.Tag.RunFilter $filterBox } } } Set-UiSubTabToolbarState -Tab $selectedTab -FilterBox $filterBox } # Get the type name from the tab header $selectedTypeName = $selectedTab.Header -replace '\s*\(\d+\)$', '' $matchesObjectType = { param($actionDef) if (!$actionDef.ObjectType) { return $true } $objectTypes = @($actionDef.ObjectType) foreach ($ot in $objectTypes) { if ($selectedTypeName -eq $ot -or $selectedTypeName -like "*.$ot" -or $selectedTypeName -match [regex]::Escape($ot)) { return $true } } return $false } # Filter individual buttons based on ObjectType foreach ($child in $capturedToolbar.Children) { if ($child -is [System.Windows.Controls.Button] -and $child.Tag -is [hashtable]) { $actionDef = $child.Tag $child.Visibility = if (& $matchesObjectType $actionDef) { 'Visible' } else { 'Collapsed' } } } # Filter dropdown menu items based on ObjectType if ($capturedDropdownMenuStack) { $visibleCount = 0 foreach ($menuItem in $capturedDropdownMenuStack.Children) { if ($menuItem -is [System.Windows.Controls.Button] -and $menuItem.Tag -is [hashtable]) { $actionDef = $menuItem.Tag $isVisible = & $matchesObjectType $actionDef $menuItem.Visibility = if ($isVisible) { 'Visible' } else { 'Collapsed' } if ($isVisible) { $visibleCount++ } } } foreach ($child in $capturedToolbar.Children) { if ($child -is [System.Windows.Controls.Button] -and $child.ToolTip -eq 'Available actions for selected items') { $child.Visibility = if ($visibleCount -gt 0) { 'Visible' } else { 'Collapsed' } } } } }.GetNewClosure()) # Trigger initial filter on first tab if ($subTabControl.Items.Count -gt 0) { $subTabControl.SelectedIndex = 0 } # Add filter box and column visibility button $filterArgs = @{ SubTabControl = $subTabControl RightToolbar = $rightToolbar FilterPanel = $filterPanel Toolbar2 = $toolbar2 SessionId = $state.OwnerSessionId } [void](Add-MultiTypeFilterControls @filterArgs) # Attach action buttons using the helper if ($ResultActions -and $ResultActions.Count -gt 0) { $allActionButtons = [System.Collections.Generic.List[object]]::new() if ($actionDropdownMenuStack) { foreach ($menuItem in $actionDropdownMenuStack.Children) { if ($menuItem -is [System.Windows.Controls.Button] -and $menuItem.Tag -is [hashtable]) { $allActionButtons.Add($menuItem) } } } # Tabs must exist before the result action handlers get them (result actions can produce their own errors/warnings) & $ensureErrorsTab & $ensureWarningsTab $actionCaptures = @{ Window = $window State = $state DataGrid = $dataGrid ResultsBorder = $resultsBorder ConsoleTab = $consoleTab ConsoleParagraph = $consoleParagraph ConsoleColorMap = $consoleColorMap AppendConsoleText = $appendConsoleText AppendState = $appendState ErrorsTab = $errorsTabState.Tab ErrorsList = $errorsTabState.List WarningsTab = $warningsTabState.Tab WarningsTextBox = $warningsTabState.TextBox WarningCount = $warningCount ProgressPanel = $progressPanel ProgressBar = $progressBar ProgressLabel = $defaultProgressUI.Label HeaderTitle = $headerTitle Title = $Title TabControl = $tabControl TabNotifications = $tabNotifications ErrorCount = [int]$errorsTabState.TotalErrors StatusSpinner = $statusSpinner StatusSuccess = $statusSuccess StatusWarning = $statusWarning StatusIndicator = $statusIndicator VarValues = $varValues FuncDefs = $funcDefs Modules = $capturedModules } Add-ResultActionClickHandlers -Captures $actionCaptures -ActionButtons $allActionButtons -DropdownPopup $dropdownPopup } # Resolve the private helpers before the closures capture. $getVisiblePaths = ${function:Get-UiDataGridVisibleColumnPaths} $formatExportRows = ${function:Format-UiDataGridExportRows} # $dataGrid is pinned to the FIRST subtab's grid. Copy and Export both need the grid the user is actually on, resolved when clicked $resolveActiveGrid = { $activeGrid = $dataGrid if ($resultsBorder.Child -is [System.Windows.Controls.TabControl]) { $selectedTab = $resultsBorder.Child.SelectedItem $activeGrid = Get-UiSubTabGrid -Tab $selectedTab } $activeGrid }.GetNewClosure() $copyButton.Add_Click({ $activeGrid = & $resolveActiveGrid if (!$activeGrid) { return } $props = @(& $getVisiblePaths -DataGrid $activeGrid) $text = & $formatExportRows -Items $activeGrid.ItemsSource -Properties $props | ConvertTo-Csv -NoTypeInformation | Out-String if ([string]::IsNullOrEmpty($text)) { return } [System.Windows.Clipboard]::SetText($text) Start-UiButtonFeedback -Button $copyButton -OriginalIconChar ([PsUi.ModuleContext]::GetIcon('Copy')) }.GetNewClosure()) # Show and hook the export button for datagrids $exportButton.Visibility = 'Visible' $exportButton.Add_Click({ $activeGrid = & $resolveActiveGrid if (!$activeGrid) { return } $saveDialog = [Microsoft.Win32.SaveFileDialog]::new() $saveDialog.Filter = 'CSV files (*.csv)|*.csv|All files (*.*)|*.*' $saveDialog.DefaultExt = '.csv' $saveDialog.FileName = "export_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" if ($saveDialog.ShowDialog()) { $props = @(& $getVisiblePaths -DataGrid $activeGrid) & $formatExportRows -Items $activeGrid.ItemsSource -Properties $props | Export-Csv -Path $saveDialog.FileName -NoTypeInformation Start-UiButtonFeedback -Button $exportButton -OriginalIconChar ([PsUi.ModuleContext]::GetIcon('Export')) } }.GetNewClosure()) } else { # Text output with search capability $textLines = if ($dataArray.Count -gt 1) { $dataArray } else { @($dataArray[0].ToString()) } $richTextBox = New-TextDisplayRichTextBox -Colors $colors -Lines $textLines $resultsBorder.Child = $richTextBox Add-TextResultsFilter -RichTextBox $richTextBox -FilterPanel $filterPanel -Toolbar2 $toolbar2 $copyButton.Add_Click({ $textRange = [System.Windows.Documents.TextRange]::new($richTextBox.Document.ContentStart, $richTextBox.Document.ContentEnd) [System.Windows.Clipboard]::SetText($textRange.Text) Start-UiButtonFeedback -Button $copyButton -OriginalIconChar ([PsUi.ModuleContext]::GetIcon('Copy')) }.GetNewClosure()) } $resultsTab.Content = $resultsPanel if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Adding resultsTab to tabControl - resultsPanel has content: " + $resultsPanel.Children.Count) } $tabControl.Items.Insert(0, $resultsTab) $tabControl.SelectedIndex = 0 if ($state.DebugEnabled) { [Console]::WriteLine("[DEBUG] Results tab inserted and selected") } } else { # No results, so check for console, error, or warning output. $hasConsoleOutput = $consoleParagraph.Inlines.Count -gt 0 $hasErrors = [int]$errorsTabState.TotalErrors -gt 0 $hasWarnings = $warningCount.Value -gt 0 if ($hasConsoleOutput -or $hasErrors -or $hasWarnings) { if ($consoleTab.Visibility -eq 'Visible') { $tabControl.SelectedItem = $consoleTab } elseif ($errorsTabState.Tab -and $errorsTabState.Tab.Visibility -eq 'Visible') { $tabControl.SelectedItem = $errorsTabState.Tab } elseif ($warningsTabState.Tab -and $warningsTabState.Tab.Visibility -eq 'Visible') { $tabControl.SelectedItem = $warningsTabState.Tab } } else { # No output at all, so show the success message. $loadingSpinner.Visibility = 'Collapsed' $successIcon = [System.Windows.Controls.TextBlock]@{ Text = [PsUi.ModuleContext]::GetIcon('Accept') FontFamily = [PsUi.ModuleContext]::ActiveIconFontFamily FontSize = 48 Foreground = ConvertTo-UiBrush '#107C10' HorizontalAlignment = 'Center' Margin = [System.Windows.Thickness]::new(0, 0, 0, 12) } $loadingStack.Children.Insert(0, $successIcon) $loadingLabel.Text = 'Completed successfully. No output was returned.' $loadingLabel.FontSize = 13 $loadingPanel.Visibility = 'Visible' } } } catch { [Console]::WriteLine("[PsUi] OnComplete error: " + $_.Exception.Message) if ($state.DebugEnabled) { [Console]::WriteLine("DEBUG ERROR StackTrace: " + $_.Exception.StackTrace) } # Insert the error in the console tab so the user isn't staring at a blank window try { if ($appendConsoleText -and $appendState) { & $appendConsoleText "Internal error rendering results: $($_.Exception.Message)" 'Red' $null -State $appendState } } catch { Write-Debug "Failed to surface OnComplete error to console" } } } |