private/output/New-ObjectSubTab.ps1
|
function New-ObjectSubTab { <# .SYNOPSIS Creates a DataGrid sub-tab for displaying rows of objects. #> [CmdletBinding()] param( [Parameter(Mandatory)] [System.Collections.Generic.List[object]]$GroupItems, [Parameter(Mandatory)] [string]$TypeName, [Parameter(Mandatory)] [System.Windows.Controls.TabControl]$SubTabControl, [switch]$SingleSelect, [switch]$IncludeActionStatus ) $subTab = [System.Windows.Controls.TabItem]::new() $subTab.Header = "$TypeName ($($GroupItems.Count))" Set-TabItemStyle -TabItem $subTab $selectParam = if ($SingleSelect) { @{ SingleSelect = $true } } else { @{} } $subGrid = New-StyledDataGrid @selectParam # Build items in a List first (avoids UI notifications during population) # WPF binds CLR members only, so an unwrapped DirectoryInfo renders Mode and every other PS* column blank. # [psobject] not [object], since an [object] typed Add unwraps it again and every collection these rows touch has to match. $itemList = [System.Collections.Generic.List[psobject]]::new($GroupItems.Count) foreach ($item in $GroupItems) { if ($IncludeActionStatus) { $props = $item.PSObject.Properties if (!$props['_ActionStatus']) { $props.Add([System.Management.Automation.PSNoteProperty]::new('_ActionStatus', '')) } } $itemList.Add($item) } # Create ObservableCollection (single notification vs per-item) # [object] here undoes all of that on the first keystroke, since the filter clears this collection and adds the rows back. # It is also why $grid.Items.IndexOf and $grid.SelectedItem = $row never match from PS, so reach for SelectedIndex or this collection. $observable = [System.Collections.ObjectModel.ObservableCollection[psobject]]::new($itemList) $subGrid.ItemsSource = [System.Windows.Data.CollectionViewSource]::GetDefaultView($observable) $allProps = [System.Collections.Generic.List[object]]::new() $defaultProps = [System.Collections.Generic.List[object]]::new() if ($GroupItems.Count -gt 0) { $firstItem = $GroupItems[0] $columnResult = Add-DataGridColumns -DataGrid $subGrid -FirstItem $firstItem -IncludeActionStatus:$IncludeActionStatus $allProps = $columnResult.AllProperties $defaultProps = $columnResult.DefaultProperties Set-LastDataColumnStar -DataGrid $subGrid } # Pre-compute populated properties for "Has Data" filtering $populatedProps = Get-PopulatedProperties -Items $GroupItems -PropertyNames $allProps $subGrid.Tag = @{ AllProperties = $allProps DefaultProperties = $defaultProps PopulatedProperties = $populatedProps UnfilteredItems = $itemList Observable = $observable } Add-ArrayCellPopupHandler -DataGrid $subGrid Add-DictionaryValuePopupHandler -DataGrid $subGrid # The New-UiDataGrid overlay, with the filter rewriting its message when a search empties the grid. $subTab.Content = Add-UiDataGridEmptyOverlay -HostControl $subGrid -DataGrid $subGrid -Message 'No items to display.' # Mark tab as indexing, then start background search index $subTab.Tag = 'Indexing' $items = @($observable) $runspace = [runspacefactory]::CreateRunspace() $runspace.Open() $ps = [powershell]::Create() $ps.Runspace = $runspace # Build search text for each item in background [void]$ps.AddScript({ param($items) # Process.CommandLine runs about 30ms a row on 7, so that one column costs eight seconds over 264 processes with the search box dead until it lands. # It prices a few rows first, then indexes every row against the same set, so a match means the same thing on row 1 and row 300. # Ten rows rather than one, since Name, Path and StartTime are cheap everywhere and are exactly what people search on. $budgetMs = 750 $sampleRows = [math]::Min(10, $items.Count) $spent = @{} $sampled = @{} $skip = [System.Collections.Generic.HashSet[string]]::new() $probe = [System.Diagnostics.Stopwatch]::new() # The sample spreads first to last, because one type bucket can mix property sets and a property no sampled row carries never gets evaluated. # The stride has to reach the last row, so it divides by (sampleRows - 1). $lastRow = $items.Count - 1 for ($sampleIdx = 0; $sampleIdx -lt $sampleRows; $sampleIdx++) { $rowIdx = if ($sampleRows -gt 1) { [int][math]::Floor($sampleIdx * $lastRow / ($sampleRows - 1)) } else { 0 } foreach ($prop in $items[$rowIdx].PSObject.Properties) { $probeName = $prop.Name if ($probeName.StartsWith('_') -or $skip.Contains($probeName)) { continue } # The first read of a property compiles its getter, so a property new to the sample gets a warm up read that never counts. # If that warm up read alone projects past twenty budgets, it gets skipped, compile time or not. if (!$sampled.ContainsKey($probeName)) { $probe.Restart() try { $null = $prop.Value } catch { [void]$skip.Add($probeName); continue } $probe.Stop() if (($probe.Elapsed.TotalMilliseconds * $items.Count) -gt ($budgetMs * 20)) { [void]$skip.Add($probeName); continue } } $probe.Restart() try { $sample = $prop.Value if ($sample) { [void]$sample.ToString() } } catch { [void]$skip.Add($probeName) continue } $probe.Stop() $spent[$probeName] = $spent[$probeName] + $probe.Elapsed.TotalMilliseconds $sampled[$probeName] = $sampled[$probeName] + 1 # Two readings both over four budgets is enough to stop, since one on its own can just be a GC pause. if ($sampled[$probeName] -ge 2 -and (($spent[$probeName] / $sampled[$probeName]) * $items.Count) -gt ($budgetMs * 4)) { [void]$skip.Add($probeName) } } } # The cost is projected over the whole set, or a flat per read cap drops a 20ms property on 20 rows that only costs 400ms to index. foreach ($probeName in @($spent.Keys)) { if ($skip.Contains($probeName)) { continue } if ((($spent[$probeName] / $sampled[$probeName]) * $items.Count) -gt $budgetMs) { [void]$skip.Add($probeName) } } foreach ($item in $items) { $props = $item.PSObject.Properties if ($props['_SearchText']) { continue } $sb = [System.Text.StringBuilder]::new() foreach ($prop in $props) { $propName = $prop.Name if ($propName.StartsWith('_') -or $skip.Contains($propName)) { continue } # No sampled row carried this one, so it gets the warm up read and the price check here before it goes in. if (!$sampled.ContainsKey($propName)) { $probe.Restart() try { $null = $prop.Value } catch { [void]$skip.Add($propName); continue } $probe.Stop() if (($probe.Elapsed.TotalMilliseconds * $items.Count) -gt ($budgetMs * 20)) { [void]$skip.Add($propName); continue } $probe.Restart() try { $propVal = $prop.Value if ($propVal) { [void]$propVal.ToString() } } catch { [void]$skip.Add($propName) continue } $probe.Stop() $sampled[$propName] = 1 if (($probe.Elapsed.TotalMilliseconds * $items.Count) -gt $budgetMs) { [void]$skip.Add($propName); continue } } else { $propVal = $prop.Value } # ToString on a list is just its type name, so a search for a value inside an array column never matches. # [PsUi.ValueKind] rather than Get-UiValueKind, since this block runs in a runspace without the module and the assembly is loaded process wide. # The pricing above reads the property and calls ToString, so it never measures this call and the 25 and 512 caps are what bound it. if ($propVal) { [void]$sb.Append([PsUi.ValueKind]::IndexText($propVal, 25, 512)) [void]$sb.Append(' ') } } $props.Add([System.Management.Automation.PSNoteProperty]::new('_SearchText', $sb.ToString())) } # The names that priced out, so the filter box can say what it won't match. $skip }).AddArgument($items) $asyncResult = $ps.BeginInvoke() $pollTimer = [System.Windows.Threading.DispatcherTimer]::new() $pollTimer.Interval = [TimeSpan]::FromMilliseconds(50) $pollTimer.Tag = @{ AsyncResult = $asyncResult PowerShell = $ps Runspace = $runspace Tab = $subTab SubTabControl = $SubTabControl } $pollTimer.Add_Tick({ $pt = $this.Tag if ($pt.AsyncResult.IsCompleted) { $this.Stop() $skipped = @() try { $skipped = @($pt.PowerShell.EndInvoke($pt.AsyncResult)) } catch { Write-Debug "Suppressed async EndInvoke cleanup error: $_" } $pt.Tab.Resources['__SkippedColumns'] = $skipped $pt.PowerShell.Dispose() $pt.Runspace.Close() $pt.Tab.Tag = 'Indexed' # Only the tab on screen drives the box, or an index landing behind another tab would switch it on for the wrong one. $filterBox = $pt.SubTabControl.Tag if ($filterBox -and $pt.SubTabControl.SelectedItem -eq $pt.Tab) { Set-UiSubTabToolbarState -Tab $pt.Tab -FilterBox $filterBox } } }) $pollTimer.Start() return @{ Tab = $subTab DataGrid = $subGrid } } |