Private/Console/Progress.psm1
|
using namespace System using namespace System.Collections.Generic using namespace System.Threading using namespace System.Threading.Tasks using module ..\Enums.psm1 using module ..\Abstracts.psm1 using module .\Colors.psm1 using module .\Internal.psm1 using module .\Rendering.psm1 using module .\Ansi.psm1 using module .\Spinners.psm1 using module .\Layout.psm1 using module .\Widgets.psm1 class ProgressTaskState { [object]$SyncRoot [int]$Id [string]$Description [double]$Value [double]$MaxValue [bool]$IsIndeterminate [bool]$IsCompleted [DateTime]$StartedAt [Nullable[DateTime]]$StoppedAt [bool] get_IsStarted() { return $this.StartedAt -ne [DateTime]::MinValue } [bool] get_IsFinished() { return $this.IsCompleted } ProgressTaskState([int]$id, [string]$description, [double]$maxValue) { $this.SyncRoot = [object]::new() $this.Id = $id $this.Description = $description $this.Value = 0 $this.MaxValue = [Math]::Max(1, $maxValue) $this.IsIndeterminate = $false $this.IsCompleted = $false $this.StartedAt = [DateTime]::UtcNow } [double] Percent() { [Monitor]::Enter($this.SyncRoot) try { if ($this.IsIndeterminate) { return 0 } return [Math]::Min(100, ($this.Value / $this.MaxValue) * 100) } finally { [Monitor]::Exit($this.SyncRoot) } } [ProgressTaskState] Snapshot() { [Monitor]::Enter($this.SyncRoot) try { $copy = [ProgressTaskState]::new($this.Id, $this.Description, $this.MaxValue) $copy.Value = $this.Value $copy.IsIndeterminate = $this.IsIndeterminate $copy.IsCompleted = $this.IsCompleted $copy.StartedAt = $this.StartedAt $copy.StoppedAt = $this.StoppedAt return $copy } finally { [Monitor]::Exit($this.SyncRoot) } } } class ProgressTask { hidden [ProgressTaskState]$_state [Action]$OnUpdate ProgressTask([ProgressTaskState]$state) { $this._state = $state } hidden [void] TriggerUpdate() { if ($null -ne $this.OnUpdate) { $this.OnUpdate.Invoke() } } [ProgressTaskState] GetState() { return $this._state.Snapshot() } [void] Increment([double]$delta) { [Monitor]::Enter($this._state.SyncRoot) try { $this._state.Value = [Math]::Min($this._state.MaxValue, $this._state.Value + $delta) if ($this._state.Value -ge $this._state.MaxValue) { $this._state.IsCompleted = $true $this._state.StoppedAt = [DateTime]::UtcNow } } finally { [Monitor]::Exit($this._state.SyncRoot) } $this.TriggerUpdate() } [void] SetValue([double]$value) { [Monitor]::Enter($this._state.SyncRoot) try { $this._state.Value = [Math]::Max(0, [Math]::Min($this._state.MaxValue, $value)) if ($this._state.Value -ge $this._state.MaxValue) { $this._state.IsCompleted = $true $this._state.StoppedAt = [DateTime]::UtcNow } } finally { [Monitor]::Exit($this._state.SyncRoot) } $this.TriggerUpdate() } [void] SetDescription([string]$description) { [Monitor]::Enter($this._state.SyncRoot) try { $this._state.Description = $description } finally { [Monitor]::Exit($this._state.SyncRoot) } $this.TriggerUpdate() } [void] Start() { [Monitor]::Enter($this._state.SyncRoot) try { $this._state.StartedAt = [DateTime]::UtcNow $this._state.IsCompleted = $false $this._state.StoppedAt = $null } finally { [Monitor]::Exit($this._state.SyncRoot) } $this.TriggerUpdate() } [void] Complete() { [Monitor]::Enter($this._state.SyncRoot) try { $this._state.Value = $this._state.MaxValue $this._state.IsCompleted = $true $this._state.StoppedAt = [DateTime]::UtcNow } finally { [Monitor]::Exit($this._state.SyncRoot) } $this.TriggerUpdate() } } class ProgressConfig : RenderOptions { [RGB]$ProgressBarColor [RGB]$ProgressMsgColor [string]$ProgressBlock ProgressConfig() : base() { $this.Reset() } ProgressConfig($hashtable) { $this.Reset() } ProgressConfig([hashtable[]]$array): base($array) { $this.Reset() } [void] Reset() { $this.ProgressBarColor = "LightSeaGreen" $this.ProgressMsgColor = "LightGoldenrodYellow" $this.ProgressBlock = '■' $this.PsObject.Properties.Add([PSscriptProperty]::new("ShowProgress", { return (Get-Variable 'VerbosePreference' -ValueOnly) -eq 'Continue' })) } # Returns [ProgressConfig] but declared as [RenderOptions] to avoid PS class self-referential return type parse error. static [RenderOptions] Create([object]$writer, [object]$capabilities) { $cfg = [ProgressConfig]::new() if ($null -ne $capabilities) { $cfg.ColorSystem = $capabilities.ColorSystem $cfg.Ansi = $capabilities.Ansi } return $cfg } [RenderOptions] ToRenderOptions() { # copy all base properties $o = [RenderOptions]@{} # CRITICAL BUG FIX PRESERVATION (#1 PowerShell ETS): # This loop MUST iterate over $o.PsObject.Properties, NOT $this! # $this (ProgressConfig) dynamically injects PSScriptProperties like 'ShowProgress'. # Iterating over $this would attempt to assign these to $o, aborting the timer thread. foreach ($name in $o.PsObject.Properties.Name) { $o.$name = $this.$name } return $o } } class ProgressTaskSettings : PsRecord { [double]$MaxValue = 100 [bool]$AutoStart = $true [bool]$IsIndeterminate = $false ProgressTaskSettings() : base() {} ProgressTaskSettings($hashtable): base($hashtable) {} ProgressTaskSettings([hashtable[]]$array): base($array) {} } class ProgressContext : PsRecord { hidden [List[ProgressTaskState]]$_tasks = [List[ProgressTaskState]]::new() hidden [int]$_nextId = 1 hidden [object]$_syncRoot = [object]::new() [Action]$OnUpdate ProgressContext() : base() {} ProgressContext($hashtable): base($hashtable) {} ProgressContext([hashtable[]]$array): base($array) {} [ProgressTask] AddTask([string]$description, [ProgressTaskSettings]$settings) { if ($null -eq $settings) { $settings = [ProgressTaskSettings]::new() } [Monitor]::Enter($this._syncRoot) try { $state = [ProgressTaskState]::new($this._nextId, $description, $settings.MaxValue) $state.IsIndeterminate = $settings.IsIndeterminate if (!$settings.AutoStart) { $state.StartedAt = [DateTime]::MinValue } $this._tasks.Add($state) $this._nextId++ $task = [ProgressTask]::new($state) $task.OnUpdate = $this.OnUpdate return $task } finally { [Monitor]::Exit($this._syncRoot) } } [ProgressTaskState[]] GetTasks() { [Monitor]::Enter($this._syncRoot) try { $snapshot = [List[ProgressTaskState]]::new() foreach ($task in $this._tasks) { $snapshot.Add($task.Snapshot()) } return $snapshot.ToArray() } finally { [Monitor]::Exit($this._syncRoot) } } [bool] IsFinished() { foreach ($task in $this.GetTasks()) { if (!$task.IsCompleted -and !$task.IsIndeterminate) { return $false } } return $true } } class LiveDisplayRegion : IDisposable { hidden [AnsiWriter]$_writer hidden [object]$_syncRoot hidden [int]$_lineCount hidden [bool]$_cursorHidden hidden [bool]$_supportsAnsi LiveDisplayRegion([AnsiWriter]$writer) { $this._writer = $writer $this._syncRoot = [object]::new() $this._lineCount = 0 $this._cursorHidden = $false $this._supportsAnsi = $writer.Capabilities.Ansi } [void] Begin() { if ($this._supportsAnsi -and !$this._cursorHidden) { $this._writer.Write("`e[?25l") $this._cursorHidden = $true } } [void] Render([string[]]$lines) { if ($null -eq $lines) { $lines = [string[]]@() } [Monitor]::Enter($this._syncRoot) try { try { $this.Begin() if (!$this._supportsAnsi) { foreach ($line in $lines) { $this._writer.WriteLine($line) } return } $targetCount = [Math]::Max($this._lineCount, $lines.Length) if ($this._lineCount -gt 0) { $this._writer.Write(("`e[{0}F" -f $this._lineCount)) } for ($index = 0; $index -lt $targetCount; $index++) { $this._writer.Write("`e[2K") if ($index -lt $lines.Length) { $this._writer.Write($lines[$index], [Style]::Plain) } # Always newline after each line so the cursor sits BELOW the progress # region. This ensures \e[{n}F on the next tick moves back exactly to # the start of the progress area and not one line further up into any # prior Write-Host output. $this._writer.WriteLine() } $this._lineCount = $targetCount } catch { # throwing an error at this stage crashes the terminal! so don't do it. $m = "[LiveDisplayRegion] Render exception: $($_.Exception.Message)`n {0}" -f $($_.ScriptStackTrace -join "`n") Write-Warning -Message $m } } finally { [Monitor]::Exit($this._syncRoot) } } [void] Complete([string[]]$lines) { $this.Render($lines) $this.Dispose() # No extra WriteLine() here: Render() now always terminates every line # with a newline, so the cursor is already on a fresh line after Render(). } [void] Dispose() { if ($this._supportsAnsi -and $this._cursorHidden) { $this._writer.Write("`e[?25h") $this._cursorHidden = $false } } } class ConsoleResolver { static [AnsiWriter] ResolveWriter([object]$consoleOrWriter) { if ($null -eq $consoleOrWriter) { throw [ArgumentNullException]::new('consoleOrWriter') } if ($consoleOrWriter -is [AnsiWriter]) { return [AnsiWriter]$consoleOrWriter } if ($consoleOrWriter -is [IAnsiConsole]) { $writer = $consoleOrWriter.GetWriter() if ($writer -is [AnsiWriter]) { return [AnsiWriter]$writer } } throw [ArgumentException]::new('Expected an AnsiWriter or IAnsiConsole-compatible object.', 'consoleOrWriter') } static [IAnsiConsole] ResolveConsole([object]$consoleOrWriter) { if ($null -eq $consoleOrWriter) { throw [ArgumentNullException]::new('consoleOrWriter') } if ($consoleOrWriter -is [IAnsiConsole]) { return [IAnsiConsole]$consoleOrWriter } throw [ArgumentException]::new('Expected an IAnsiConsole-compatible object.', 'consoleOrWriter') } } class ProgressColumn { [Progress]$Owner ProgressColumn() {} ProgressColumn([Progress]$owner) { $this.Owner = $owner } [bool] get_NoWrap() { return $true } [Nullable[int]] GetColumnWidth([RenderOptions]$options) { return $null } [IRenderable] Render([RenderOptions]$options, [ProgressTaskState]$task, [TimeSpan]$deltaTime) { throw [NotImplementedException]::new() } } class TaskDescriptionColumn : ProgressColumn { [Justify]$Alignment = [Justify]::Left TaskDescriptionColumn() : base() {} TaskDescriptionColumn([Progress]$owner) : base($owner) {} [IRenderable] Render([RenderOptions]$options, [ProgressTaskState]$task, [TimeSpan]$deltaTime) { $text = if ($null -ne $task.Description) { $task.Description } else { "" } $m = [Markup]::new($text) $m.Overflow = [Overflow]::Ellipsis $m.Justify = $this.Alignment return $m } } class PercentageColumn : ProgressColumn { [Style]$Style = [Style]::Parse("green") [Style]$CompletedStyle = [Style]::Parse("green") PercentageColumn() : base() {} PercentageColumn([Progress]$owner) : base($owner) {} [Nullable[int]] GetColumnWidth([RenderOptions]$options) { return 4 } [IRenderable] Render([RenderOptions]$options, [ProgressTaskState]$task, [TimeSpan]$deltaTime) { $pct = $task.Percent() $styleToUse = if ($task.get_IsFinished()) { $this.CompletedStyle } else { $this.Style } $text = '{0,3:N0}%' -f $pct return [Markup]::new($text, $styleToUse) } } class SpinnerColumn : ProgressColumn { [Spinner]$Spinner static [Spinner]$_defaultSpinnerCACHE static [Spinner]$_asciiSpinnerCACHE [Style]$Style = [Style]::Parse("yellow") [Style]$CompletedStyle = [Style]::Parse("green") [string]$CompletedText = '✓' [string]$PendingText = ' ' SpinnerColumn() : base() {} SpinnerColumn([Progress]$owner) : base($owner) {} hidden [void] EnsureSpinner() { if ($null -eq $this.Spinner -or $null -eq $this.Spinner.Frames) { if ($null -eq [SpinnerColumn]::_defaultSpinnerCACHE) { [SpinnerColumn]::_defaultSpinnerCACHE = [Spinner]::new('Default') } $this.Spinner = [SpinnerColumn]::_defaultSpinnerCACHE } if ($null -eq [SpinnerColumn]::_asciiSpinnerCACHE) { [SpinnerColumn]::_asciiSpinnerCACHE = [Spinner]::new('Ascii') } } [Nullable[int]] GetColumnWidth([RenderOptions]$options) { $this.EnsureSpinner() $frames = if ($options.Unicode -or $this.Spinner.IsUnicode -eq $false) { $this.Spinner.Frames } else { [SpinnerColumn]::_asciiSpinnerCACHE.Frames } if ($null -eq $frames -or $frames.Length -eq 0) { return 1 } $maxWidth = 1 foreach ($frame in $frames) { $len = [Cell]::GetCellLength($frame) if ($len -gt $maxWidth) { $maxWidth = $len } } return $maxWidth } hidden [double]$_accumulated = 0 hidden [int]$_index = 0 [IRenderable] Render([RenderOptions]$options, [ProgressTaskState]$task, [TimeSpan]$deltaTime) { if (!$task.get_IsStarted()) { return [Markup]::new($this.PendingText, [Style]::Plain) } if ($task.get_IsFinished()) { return [Markup]::new($this.CompletedText, $this.CompletedStyle) } $this.EnsureSpinner() $this._accumulated += $deltaTime.TotalMilliseconds if ($this._accumulated -ge $this.Spinner.Interval.TotalMilliseconds) { $this._accumulated -= $this.Spinner.Interval.TotalMilliseconds $this._index++ } $spinnerList = if ($options.Unicode -or $this.Spinner.IsUnicode -eq $false) { $this.Spinner.Frames } else { [SpinnerColumn]::_asciiSpinnerCACHE.Frames } if ($null -eq $spinnerList -or $spinnerList.Length -eq 0) { return [Markup]::new(" ", $this.Style) } $frame = $spinnerList[$this._index % $spinnerList.Length] return [Markup]::new($frame, $this.Style) } } class ProgressBarColumn : ProgressColumn { [int]$Width = 40 [Style]$CompletedStyle = [Style]::Parse("green") [Style]$FinishedStyle = [Style]::Parse("green") [Style]$RemainingStyle = [Style]::Parse("grey") ProgressBarColumn() : base() {} ProgressBarColumn([Progress]$owner) : base($owner) {} [Nullable[int]] GetColumnWidth([RenderOptions]$options) { return $this.Width } [IRenderable] Render([RenderOptions]$options, [ProgressTaskState]$task, [TimeSpan]$deltaTime) { $cfg = if ($options -is [ProgressConfig]) { [ProgressConfig]$options } else { $null } $renderable = [ProgressBarRenderable]::new($task, $this.Width, $this.CompletedStyle, $this.RemainingStyle, $this.FinishedStyle) $renderable.Options = $cfg return $renderable } } class ProgressBarRenderable : IRenderable { [ProgressTaskState]$Task [int]$Width [Style]$CompletedStyle [Style]$RemainingStyle [Style]$FinishedStyle # Stored by ProgressBarColumn.Render() so the ProgressConfig survives the Grid's # generic Render([RenderOptions],int) call contract. # Typed [object] to avoid PowerShell parse-time forward-reference errors. [object]$Options ProgressBarRenderable([ProgressTaskState]$task, [int]$width, [Style]$completedStyle, [Style]$remainingStyle, [Style]$finishedStyle) { $this.Task = $task $this.Width = $width $this.CompletedStyle = $completedStyle $this.RemainingStyle = $remainingStyle $this.FinishedStyle = $finishedStyle } # Override Measure() so the Grid column measurer sees the bar's actual width # instead of the full maxWidth (which would crowd out the percentage/spinner columns). [Measurement] Measure([RenderOptions]$options, [int]$maxWidth) { $safeWidth = [Math]::Min($this.Width, $maxWidth) return [Measurement]::new($safeWidth, $safeWidth) } # Grid calls Render([RenderOptions], int) — we recover ProgressConfig from $this.Options. [object[]] Render([RenderOptions]$options, [int]$maxWidth) { $cfg = if ($null -ne $this.Options) { $this.Options } elseif ($options -is [ProgressConfig]) { [ProgressConfig]$options } else { [ProgressConfig]::new() } $segs = [List[Segment]]::new() $safeWidth = [Math]::Min($this.Width, $maxWidth) $block = if ($cfg.ProgressBlock) { $cfg.ProgressBlock } else { '■' } if ($this.Task.IsIndeterminate) { $segs.Add([Segment]::new(('.' * $safeWidth), $this.RemainingStyle)) return $segs.ToArray() } $pct = $this.Task.Percent() $filledCount = [Math]::Min($safeWidth, [int][Math]::Floor(($pct / 100) * $safeWidth)) $emptyCount = [Math]::Max(0, $safeWidth - $filledCount) $actualCompStyle = if ($this.Task.get_IsFinished()) { $this.FinishedStyle } else { $this.CompletedStyle } if ($filledCount -gt 0) { $segs.Add([Segment]::new(($block * $filledCount), $actualCompStyle)) } if ($emptyCount -gt 0) { $segs.Add([Segment]::new(('=' * $emptyCount), $this.RemainingStyle)) } return $segs.ToArray() } } class ProgressRenderable : IRenderable { [Progress]$Owner [ProgressContext]$Context [TimeSpan]$DeltaTime ProgressRenderable([Progress]$owner, [ProgressContext]$context, [TimeSpan]$deltaTime) { $this.Owner = $owner $this.Context = $context $this.DeltaTime = $deltaTime } [Segment[]] Render([RenderOptions]$options, [int]$maxWidth) { try { # CRITICAL BUG FIX PRESERVATION (#2 Background Runspaces vs Renderers): # This Progress Engine renders explicitly via linear column formatting instead of using [Grid]. # [Grid] leverages recursive TableMeasurers which can fail abruptly with background # [System.Threading.Timer] threads if [Segment]::Truncate aborts during a tight layout scale. $cfg = if ($options -is [ProgressConfig]) { [ProgressConfig]$options } else { [ProgressConfig]::new() } $tasks = $this.Context.GetTasks() $renderOpts = $cfg.ToRenderOptions() $result = [List[Segment]]::new() # Calculate exact max constraints across all tasks to align them as a simulated grid $colWidths = [int[]]::new($this.Owner.Columns.Count) foreach ($task in $tasks) { for ($i = 0; $i -lt $this.Owner.Columns.Count; $i++) { $col = $this.Owner.Columns[$i] $w = $col.GetColumnWidth($cfg) if ($null -ne $w) { $colWidths[$i] = [Math]::Max($colWidths[$i], [int]$w) } else { # Pass zero DeltaTime during measurement phase to prevent double-ticking animations $r = $col.Render($cfg, $task, [TimeSpan]::Zero) if ($null -ne $r) { # CRITICAL BUG FIX PRESERVATION (#3 PowerShell Polymorphism): # We wrap $r inside an array @($r)[0] to guarantee we evaluate the concrete instance. # In certain PS versions passing interfaces natively over virtual tables fails dispatching to inherited classes. $m = @($r)[0].Measure($renderOpts, $maxWidth) $colWidths[$i] = [Math]::Max($colWidths[$i], [int]$m.Max) } } } } # Manual sequence emission foreach ($task in $tasks) { $lineSegments = [List[Segment]]::new() for ($i = 0; $i -lt $this.Owner.Columns.Count; $i++) { $col = $this.Owner.Columns[$i] $renderable = $col.Render($cfg, $task, $this.DeltaTime) if ($null -eq $renderable) { continue } $colW = $colWidths[$i] $rendered = @($renderable)[0].Render($renderOpts, $colW) # Avoid AddRange unwrapping anomalies foreach ($s in $rendered) { $lineSegments.Add($s) } $actualLen = [Segment]::CellCount($rendered) if ($actualLen -lt $colW) { $pad = " " * ($colW - $actualLen) $lineSegments.Add([Segment]::new($pad, [Style]::Plain)) } if ($i -lt $this.Owner.Columns.Count - 1) { $lineSegments.Add([Segment]::new(" ", [Style]::Plain)) } } if ([Segment]::CellCount($lineSegments) -gt $maxWidth) { foreach ($s in [Segment]::Truncate($lineSegments, $maxWidth)) { $result.Add($s) } } else { foreach ($s in $lineSegments) { $result.Add($s) } } $result.Add([Segment]::LineBreak) } return $result.ToArray() } catch { Write-Warning "[ProgressRenderable] Render exception: $_" return @([Segment]::new(" [Render Error] ", [Style]::Plain)) } } } class ProgressLiveSession { [Progress]$Owner [ProgressContext]$Context [LiveDisplayRegion]$Display [int]$Frame = 0 [DateTime]$LastUpdate = [DateTime]::UtcNow [string[]]$LastLines = [string[]]@() ProgressLiveSession([Progress]$Owner) { $this.Owner = $Owner $this.PsObject.Properties.Add([PSscriptProperty]::new("settings", { return $this.Owner.Config }, { param($settings) $this.Owner.Config = ($settings -is [ProgressConfig]) ? $settings : [ProgressConfig]::new($settings) })) } ProgressLiveSession([Progress]$Owner, [ProgressContext]$context, [LiveDisplayRegion]$display) { $this.Owner = $Owner $this.Context = $context $this.Display = $display $this.PsObject.Properties.Add([PSscriptProperty]::new("settings", { return $this.Owner.Config }, { param($settings) $this.Owner.Config = ($settings -is [ProgressConfig]) ? $settings : [ProgressConfig]::new($settings) })) } [void] Tick([object]$state) { try { $now = [DateTime]::UtcNow $delta = $now - $this.LastUpdate $this.LastUpdate = $now $renderable = [ProgressRenderable]::new($this.Owner, $this.Context, $delta) $options = [ProgressConfig]::Create($this.Owner.Writer, $this.Owner.Writer.Capabilities) [Segment[]]$segs = $renderable.Render($options, $this.Owner.GetRenderWidth()) $lines = [Segment]::SplitLines($segs, $this.Owner.GetRenderWidth()) $renderedLines = [List[string]]::new() foreach ($line in $lines) { $sb = [Text.StringBuilder]::new() foreach ($seg in $line.Segments) { $hasColor = $this.Owner.Writer.Capabilities.Ansi -and $null -ne $seg.Style -and $seg.Style -ne [Style]::Plain if ($hasColor) { [void]$sb.Append("`e[" + [AnsiCodeBuilder]::GetAnsi($seg.Style, $this.Owner.Writer.Capabilities.ColorSystem) + "m") } [void]$sb.Append($seg.Text) if ($hasColor) { [void]$sb.Append("`e[0m") } } $renderedLines.Add($sb.ToString()) } $this.LastLines = $renderedLines.ToArray() $this.Frame++ $this.Display.Render($this.LastLines) } catch { Write-Warning "[ProgressLiveSession] Tick exception: $_" } } } class ProgressRefreshThread : IDisposable { hidden [Timer]$_timer ProgressRefreshThread([TimerCallback]$callback, [int]$refreshRateMs) { $period = [Math]::Max(30, $refreshRateMs) $this._timer = [Timer]::new($callback, $null, 0, $period) } [void] Dispose() { if ($null -ne $this._timer) { $this._timer.Dispose() $this._timer = $null } } } class Progress { [AnsiWriter]$Writer [int]$RefreshRateMs = 100 [List[ProgressColumn]]$Columns [ProgressLiveSession]$Session [ProgressConfig]$Config = @{} Progress([AnsiWriter]$writer) { $this.Initialize($writer) } Progress([IAnsiConsole]$console) { $this.Initialize([ConsoleResolver]::ResolveWriter($console)) } Progress([object]$consoleOrWriter) { $this.Initialize([ConsoleResolver]::ResolveWriter($consoleOrWriter)) } [void] Initialize([AnsiWriter]$Writer) { $this.Writer = $Writer $this.InitializeColumns() } hidden [void] InitializeColumns() { $this.Columns = [List[ProgressColumn]]::new() $this.Columns.Add([TaskDescriptionColumn]::new($this)) $this.Columns.Add([ProgressBarColumn]::new($this)) $this.Columns.Add([PercentageColumn]::new($this)) $this.Columns.Add([SpinnerColumn]::new($this)) } [void] Columns([ProgressColumn[]]$columns) { $this.Columns.Clear() $this.Columns.AddRange($columns) } [void] Start([Action[ProgressContext]]$action) { $context = [ProgressContext]::new() $this.Start($context, $action) } [void] Start([ProgressContext]$context, [Action[ProgressContext]]$action) { $display = [LiveDisplayRegion]::new($this.Writer) $this.session = [ProgressLiveSession]::new([Progress]$this, $context, $display) # Render synchronously on task updates to avoid PowerShell runspace deadlocks. # NOTE: $this is NOT available inside a [Action]/scriptblock closure in PowerShell # classes — capture the session reference in a local variable first. $liveSession = $this.session $context.OnUpdate = [Action] { $liveSession.Tick($null) } try { $this.session.Tick($null) # Initial render # Execute the user's action synchronously in the main runspace. $action.Invoke($context) } finally { $this.session.Tick($null) # final render (100 %) $display.Complete($this.session.LastLines) } } hidden [int] GetRenderWidth() { try { return [Math]::Max(40, [Console]::WindowWidth - 1) } catch { return 80 } } static [Task[]] RunConcurrently([ScriptBlock[]]$workItems) { $tasks = [List[Task]]::new() foreach ($workItem in $workItems) { $tasks.Add([Task]::Run([Action] { & $workItem })) } return $tasks.ToArray() } static [void] WaitAll([Task[]]$tasks) { if ($null -eq $tasks -or $tasks.Length -eq 0) { return } [Task]::WaitAll($tasks) } } |