From dcfa6aab6e75da564bced2e1b8ed86b48ffbbc48 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 01:44:48 +0800 Subject: [PATCH 1/4] ci: classify PR validation by delivery impact --- .github/workflows/winterm-full-build.yml | 148 ----------- .github/workflows/winterm-validation.yml | 256 ++++++++++++++++--- scripts/winterm/ci/ChangeClassification.psm1 | 154 +++++++++++ scripts/winterm/classify-ci-changes.ps1 | 96 +++++++ scripts/winterm/test-ci-classification.ps1 | 73 ++++++ scripts/winterm/test-release-workflow.ps1 | 39 ++- scripts/winterm/test.ps1 | 6 + 7 files changed, 591 insertions(+), 181 deletions(-) delete mode 100644 .github/workflows/winterm-full-build.yml create mode 100644 scripts/winterm/ci/ChangeClassification.psm1 create mode 100644 scripts/winterm/classify-ci-changes.ps1 create mode 100644 scripts/winterm/test-ci-classification.ps1 diff --git a/.github/workflows/winterm-full-build.yml b/.github/workflows/winterm-full-build.yml deleted file mode 100644 index 336005fae..000000000 --- a/.github/workflows/winterm-full-build.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: winTerm full Windows build - -on: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - build-and-test: - name: x64 ${{ matrix.configuration }} build and relevant tests - runs-on: windows-2022 - timeout-minutes: 180 - strategy: - fail-fast: false - matrix: - configuration: [Debug, Release] - - steps: - - name: Check out source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Validate appearance assets and licenses - shell: pwsh - run: .\scripts\winterm\validate-assets.ps1 - - - name: Verify generated third-party notices - shell: pwsh - run: .\scripts\winterm\generate-third-party-notices.ps1 -Check - - - name: Run static and smoke validation - shell: pwsh - run: .\scripts\winterm\test.ps1 -Suite Smoke -Configuration ${{ matrix.configuration }} -Platform x64 - - - name: Test Shell Experience assets - shell: pwsh - run: .\scripts\winterm\test-shell-integration.ps1 -Shell All - - - name: Validate workspace fixtures and security boundaries - shell: pwsh - run: .\scripts\winterm\validate-workspaces.ps1 -BundledFixtures - - - name: Validate directed splitting and pane resizing - shell: pwsh - run: | - .\scripts\winterm\test-pane-controls.ps1 -SourceOnly - .\scripts\winterm\test-pane-resizing.ps1 -SourceOnly - .\scripts\winterm\validate-layout.ps1 -TestFixture empty-slot.winterm-workspace.json - - - name: Build x64 - shell: pwsh - run: .\scripts\winterm\build.ps1 -Configuration ${{ matrix.configuration }} -Platform x64 -IncludeTests - - - name: Run relevant upstream tests - shell: pwsh - run: .\scripts\winterm\test.ps1 -Suite Relevant -Configuration ${{ matrix.configuration }} -Platform x64 - - - name: Run workspace benchmark - shell: pwsh - run: .\scripts\winterm\benchmark-workspace.ps1 - - package: - name: x64 unpackaged distributions and installer tests - needs: build-and-test - runs-on: windows-2022 - timeout-minutes: 300 - - steps: - - name: Check out source - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - - name: Resolve the application version - shell: pwsh - run: | - $metadata = Get-Content -LiteralPath 'src\winterm\Branding\version.json' -Raw | ConvertFrom-Json - "WINTERM_VERSION=$($metadata.applicationVersion)" >> $env:GITHUB_ENV - - - name: Install the pinned official Inno Setup compiler - shell: pwsh - run: | - $download = Join-Path $env:RUNNER_TEMP 'innosetup-6.7.3.exe' - $installRoot = Join-Path $env:RUNNER_TEMP 'Inno-Setup-6.7.3' - Invoke-WebRequest ` - -Uri 'https://github.com/jrsoftware/issrc/releases/download/is-6_7_3/innosetup-6.7.3.exe' ` - -OutFile $download - $hash = (Get-FileHash -LiteralPath $download -Algorithm SHA256).Hash - if ($hash -cne '9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732') { - throw "Pinned Inno Setup download hash mismatch: $hash" - } - $signature = Get-AuthenticodeSignature -LiteralPath $download - if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Pyrsys B\.V\.') { - throw 'The pinned Inno Setup download does not have the expected trusted signature.' - } - $install = Start-Process -FilePath $download -ArgumentList @( - '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER', "/DIR=`"$installRoot`"") ` - -WindowStyle Hidden -Wait -PassThru - if ($install.ExitCode -ne 0) { - throw "Pinned Inno Setup installation failed with exit code $($install.ExitCode)." - } - "WINTERM_INNO_COMPILER=$(Join-Path $installRoot 'ISCC.exe')" >> $env:GITHUB_ENV - - - name: Build and verify unpackaged, Setup, and Portable distributions - shell: pwsh - run: | - .\scripts\winterm\build-unpackaged.ps1 -Configuration Release -Platform x64 -Force - .\scripts\winterm\build-installer.ps1 ` - -Version $env:WINTERM_VERSION ` - -Platform x64 ` - -StageDirectory 'artifacts\stage\winTerm-x64' ` - -OutputDirectory 'artifacts\release' ` - -InnoCompiler $env:WINTERM_INNO_COMPILER ` - -SkipBuild - .\scripts\winterm\build-portable.ps1 ` - -Version $env:WINTERM_VERSION ` - -Platform x64 ` - -StageDirectory 'artifacts\stage\winTerm-x64' ` - -OutputDirectory 'artifacts\release' ` - -SkipBuild - - - name: Test current-user and all-users installation lifecycles - shell: pwsh - run: | - .\scripts\winterm\test-installer.ps1 ` - -InstallerPath "artifacts\release\winTerm-$env:WINTERM_VERSION-setup-x64.exe" ` - -Platform x64 ` - -Version $env:WINTERM_VERSION ` - -RunSilentRoundTrip ` - -RunAllUsersRoundTrip ` - -RunDefaultPathRoundTrips - .\scripts\winterm\test-portable.ps1 ` - -PortablePath "artifacts\release\winTerm-$env:WINTERM_VERSION-portable-x64.zip" ` - -Platform x64 ` - -Version $env:WINTERM_VERSION ` - -RunLaunch - - - name: Upload the exact unpackaged distribution allowlist - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: winTerm-${{ env.WINTERM_VERSION }}-x64-distributions - path: | - artifacts/release/winTerm-${{ env.WINTERM_VERSION }}-setup-x64.exe - artifacts/release/winTerm-${{ env.WINTERM_VERSION }}-portable-x64.zip - if-no-files-found: error diff --git a/.github/workflows/winterm-validation.yml b/.github/workflows/winterm-validation.yml index 53d508573..4a679615b 100644 --- a/.github/workflows/winterm-validation.yml +++ b/.github/workflows/winterm-validation.yml @@ -1,62 +1,260 @@ name: winTerm validation on: - push: pull_request: workflow_dispatch: + inputs: + validation: + description: Validation depth + required: true + default: auto + type: choice + options: + - auto + - fast + - full permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: - smoke: - name: Static and smoke validation + classify-changes: + name: Classify exact change set runs-on: windows-latest timeout-minutes: 10 + outputs: + change_class: ${{ steps.classify.outputs.change_class }} + run_fast_build: ${{ steps.classify.outputs.run_fast_build }} + run_full_build: ${{ steps.classify.outputs.run_full_build }} + run_package: ${{ steps.classify.outputs.run_package }} + steps: + - name: Check out source and history + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Classify changed files + id: classify + shell: pwsh + env: + WINTERM_CI_MODE: ${{ inputs.validation || 'auto' }} + WINTERM_CI_EVENT_NAME: ${{ github.event_name }} + WINTERM_CI_BASE_SHA: ${{ github.event.pull_request.base.sha }} + WINTERM_CI_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + WINTERM_CI_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + WINTERM_CI_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }} + run: .\scripts\winterm\classify-ci-changes.ps1 + quick-validation: + name: Quick source and smoke validation + needs: classify-changes + runs-on: windows-latest + timeout-minutes: 20 steps: - name: Check out source uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - name: Validate appearance assets and licenses + - name: Run static, identity, asset, license, shell, workspace, pane, and smoke checks shell: powershell - run: .\scripts\winterm\validate-assets.ps1 + run: .\scripts\winterm\test.ps1 -Suite Smoke -Configuration Release -Platform x64 - - name: Verify generated third-party notices + - name: Exercise PowerShell and Command Prompt integration shell: powershell - run: .\scripts\winterm\generate-third-party-notices.ps1 -Check + run: .\scripts\winterm\test-shell-integration.ps1 -Shell All - - name: Verify winTerm identity, scripts, and profile foundations + - name: Validate layout fixtures and workspace performance shell: powershell - run: .\scripts\winterm\test.ps1 -Suite Smoke + run: | + .\scripts\winterm\validate-layout.ps1 -TestFixture empty-slot.winterm-workspace.json + .\scripts\winterm\benchmark-workspace.ps1 - - name: Validate Shell Experience assets and safety boundaries - shell: powershell + fast-build: + name: x64 Release build and relevant tests + needs: + - classify-changes + - quick-validation + if: needs.classify-changes.outputs.run_fast_build == 'true' + runs-on: windows-2022 + timeout-minutes: 180 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build x64 Release and compiled tests + shell: pwsh + run: .\scripts\winterm\build.ps1 -Configuration Release -Platform x64 -IncludeTests + + - name: Run relevant x64 Release compiled tests + shell: pwsh + run: .\scripts\winterm\test.ps1 -Suite Relevant -Configuration Release -Platform x64 + + full-build: + name: x64 ${{ matrix.configuration }} build and relevant tests + needs: + - classify-changes + - quick-validation + if: needs.classify-changes.outputs.run_full_build == 'true' + runs-on: windows-2022 + timeout-minutes: 180 + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Build x64 ${{ matrix.configuration }} and compiled tests + shell: pwsh + run: .\scripts\winterm\build.ps1 -Configuration ${{ matrix.configuration }} -Platform x64 -IncludeTests + + - name: Run relevant x64 ${{ matrix.configuration }} compiled tests + shell: pwsh + run: .\scripts\winterm\test.ps1 -Suite Relevant -Configuration ${{ matrix.configuration }} -Platform x64 + + package: + name: x64 unpackaged distributions and installer lifecycle tests + needs: + - classify-changes + - full-build + if: needs.classify-changes.outputs.run_package == 'true' + runs-on: windows-2022 + timeout-minutes: 300 + steps: + - name: Check out source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Resolve the application version + shell: pwsh run: | - .\scripts\winterm\package-shell-assets.ps1 - .\scripts\winterm\test-paste-protection.ps1 + $metadata = Get-Content -LiteralPath 'src\winterm\Branding\version.json' -Raw | ConvertFrom-Json + "WINTERM_VERSION=$($metadata.applicationVersion)" >> $env:GITHUB_ENV - - name: Test PowerShell and Command Prompt integration - shell: powershell - run: .\scripts\winterm\test-shell-integration.ps1 -Shell All + - name: Install the pinned official Inno Setup compiler + shell: pwsh + run: | + $download = Join-Path $env:RUNNER_TEMP 'innosetup-6.7.3.exe' + $installRoot = Join-Path $env:RUNNER_TEMP 'Inno-Setup-6.7.3' + Invoke-WebRequest ` + -Uri 'https://github.com/jrsoftware/issrc/releases/download/is-6_7_3/innosetup-6.7.3.exe' ` + -OutFile $download + $hash = (Get-FileHash -LiteralPath $download -Algorithm SHA256).Hash + if ($hash -cne '9C73C3BAE7ED48D44112A0F48E66742C00090BDB5BEF71D9D3C056C66E97B732') { + throw "Pinned Inno Setup download hash mismatch: $hash" + } + $signature = Get-AuthenticodeSignature -LiteralPath $download + if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Pyrsys B\.V\.') { + throw 'The pinned Inno Setup download does not have the expected trusted signature.' + } + $install = Start-Process -FilePath $download -ArgumentList @( + '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CURRENTUSER', "/DIR=`"$installRoot`"") ` + -WindowStyle Hidden -Wait -PassThru + if ($install.ExitCode -ne 0) { + throw "Pinned Inno Setup installation failed with exit code $($install.ExitCode)." + } + "WINTERM_INNO_COMPILER=$(Join-Path $installRoot 'ISCC.exe')" >> $env:GITHUB_ENV - - name: Validate workspace fixtures, restore, recovery, and import security - shell: powershell + - name: Build and verify unpackaged, Setup, and Portable distributions + shell: pwsh run: | - .\scripts\winterm\test-workspace-model.ps1 - .\scripts\winterm\test-workspace-restore.ps1 - .\scripts\winterm\test-workspace-recovery.ps1 - .\scripts\winterm\test-workspace-import.ps1 + .\scripts\winterm\build-unpackaged.ps1 -Configuration Release -Platform x64 -Force + .\scripts\winterm\build-installer.ps1 ` + -Version $env:WINTERM_VERSION ` + -Platform x64 ` + -StageDirectory 'artifacts\stage\winTerm-x64' ` + -OutputDirectory 'artifacts\release' ` + -InnoCompiler $env:WINTERM_INNO_COMPILER ` + -SkipBuild + .\scripts\winterm\build-portable.ps1 ` + -Version $env:WINTERM_VERSION ` + -Platform x64 ` + -StageDirectory 'artifacts\stage\winTerm-x64' ` + -OutputDirectory 'artifacts\release' ` + -SkipBuild - - name: Validate directed splitting and pane resize transactions - shell: powershell + - name: Test current-user, all-users, and portable lifecycles + shell: pwsh run: | - .\scripts\winterm\test-pane-controls.ps1 -SourceOnly - .\scripts\winterm\test-pane-resizing.ps1 -SourceOnly + .\scripts\winterm\test-installer.ps1 ` + -InstallerPath "artifacts\release\winTerm-$env:WINTERM_VERSION-setup-x64.exe" ` + -Platform x64 ` + -Version $env:WINTERM_VERSION ` + -RunSilentRoundTrip ` + -RunAllUsersRoundTrip ` + -RunDefaultPathRoundTrips + .\scripts\winterm\test-portable.ps1 ` + -PortablePath "artifacts\release\winTerm-$env:WINTERM_VERSION-portable-x64.zip" ` + -Platform x64 ` + -Version $env:WINTERM_VERSION ` + -RunLaunch - - name: Validate layout fixtures and local UI source boundaries - shell: powershell + - name: Upload the exact unpackaged distribution allowlist + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: winTerm-${{ env.WINTERM_VERSION }}-x64-distributions + path: | + artifacts/release/winTerm-${{ env.WINTERM_VERSION }}-setup-x64.exe + artifacts/release/winTerm-${{ env.WINTERM_VERSION }}-portable-x64.zip + if-no-files-found: error + + ci-gate: + name: ci-gate + if: always() + needs: + - classify-changes + - quick-validation + - fast-build + - full-build + - package + runs-on: windows-latest + timeout-minutes: 5 + steps: + - name: Require every selected validation job + shell: pwsh + env: + CHANGE_CLASS: ${{ needs.classify-changes.outputs.change_class }} + CLASSIFY_RESULT: ${{ needs.classify-changes.result }} + QUICK_RESULT: ${{ needs.quick-validation.result }} + FAST_REQUIRED: ${{ needs.classify-changes.outputs.run_fast_build }} + FAST_RESULT: ${{ needs.fast-build.result }} + FULL_REQUIRED: ${{ needs.classify-changes.outputs.run_full_build }} + FULL_RESULT: ${{ needs.full-build.result }} + PACKAGE_REQUIRED: ${{ needs.classify-changes.outputs.run_package }} + PACKAGE_RESULT: ${{ needs.package.result }} run: | - .\scripts\winterm\validate-layout.ps1 -TestFixture empty-slot.winterm-workspace.json + $failures = [System.Collections.Generic.List[string]]::new() + if ($env:CLASSIFY_RESULT -ne 'success') { $failures.Add("classification was $env:CLASSIFY_RESULT") } + if ($env:QUICK_RESULT -ne 'success') { $failures.Add("quick validation was $env:QUICK_RESULT") } + + foreach ($job in @( + @{ Name = 'fast build'; Required = $env:FAST_REQUIRED; Result = $env:FAST_RESULT }, + @{ Name = 'full build'; Required = $env:FULL_REQUIRED; Result = $env:FULL_RESULT }, + @{ Name = 'package'; Required = $env:PACKAGE_REQUIRED; Result = $env:PACKAGE_RESULT } + )) { + if ($job.Required -eq 'true' -and $job.Result -ne 'success') { + $failures.Add("$($job.Name) was required but finished as $($job.Result)") + } + elseif ($job.Required -ne 'true' -and $job.Result -ne 'skipped') { + $failures.Add("$($job.Name) was not selected but finished as $($job.Result)") + } + } + + "## CI gate`n`nSelected class: **$env:CHANGE_CLASS**" >> $env:GITHUB_STEP_SUMMARY + if ($failures.Count -gt 0) { + $failures | ForEach-Object { "- $_" >> $env:GITHUB_STEP_SUMMARY } + throw ($failures -join '; ') + } + '- All required jobs succeeded; unselected jobs were intentionally skipped.' >> $env:GITHUB_STEP_SUMMARY diff --git a/scripts/winterm/ci/ChangeClassification.psm1 b/scripts/winterm/ci/ChangeClassification.psm1 new file mode 100644 index 000000000..cbde2ed03 --- /dev/null +++ b/scripts/winterm/ci/ChangeClassification.psm1 @@ -0,0 +1,154 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +Set-StrictMode -Version Latest + +function Test-WinTermDocumentationPath +{ + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $normalized = $Path.Replace('\', '/') + if ($normalized -match '^(docs|doc)/' -and $normalized -notmatch '^docs/releases/') + { + return $true + } + if ($normalized -match '^\.github/(ISSUE_TEMPLATE/|PULL_REQUEST_TEMPLATE(?:\.md|/))') + { + return $true + } + + return $normalized -cin @( + 'AGENTS.md', + 'CHANGELOG.md', + 'CODE_OF_CONDUCT.md', + 'CODE_SIGNING_POLICY.md', + 'CONTRIBUTING.md', + 'PRIVACY.md', + 'README.md', + 'SECURITY.md', + 'SUPPORT.md' + ) +} + +function Test-WinTermDeliveryPath +{ + param( + [Parameter(Mandatory)] + [string]$Path + ) + + $normalized = $Path.Replace('\', '/') + if ($normalized -match '^\.github/(workflows|actions)/' -or + $normalized -match '^packaging/' -or + $normalized -match '^build/' -or + $normalized -match '^\.nuget/' -or + $normalized -match '^dep/(nuget|vcpkg-overlay-ports|vcpkg-overlay-triplets)/' -or + $normalized -match '^docs/releases/' -or + $normalized -match '^src/winterm/Branding/' -or + $normalized -match '^assets/winterm/(fonts|themes)/manifest\.json$') + { + return $true + } + + if ($normalized -match '(?i)(^|/)(CMakeLists\.txt|NuGet\.config|packages\.config|vcpkg\.json)$' -or + $normalized -match '(?i)\.(sln|slnx|vcxproj|wapproj|props|targets|cmake|iss|appxmanifest|manifest|rc)$') + { + return $true + } + + if ($normalized -cin @( + '.vsconfig', + 'dirs', + 'THIRD_PARTY_NOTICES.md' + )) + { + return $true + } + + if ($normalized -match '^scripts/winterm/' -and $normalized -match '(?i)(build|package|installer|portable|unpackaged|release|sign|attest|checksum|third-party-notices|winget|version|branding)') + { + return $true + } + + return $false +} + +function Get-WinTermChangeClassification +{ + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$ChangedFiles, + + [Parameter()] + [AllowEmptyCollection()] + [string[]]$Labels = @(), + + [Parameter()] + [ValidateSet('auto', 'fast', 'full')] + [string]$Mode = 'auto' + ) + + $normalizedFiles = @($ChangedFiles | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim().Replace('\', '/') } | Sort-Object -Unique) + $normalizedLabels = @($Labels | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim().ToLowerInvariant() }) + $reasons = [System.Collections.Generic.List[string]]::new() + + if ($Mode -eq 'full') + { + $changeClass = 'delivery' + $reasons.Add('workflow_dispatch selected full validation') + } + elseif ($Mode -eq 'fast') + { + $changeClass = 'code' + $reasons.Add('workflow_dispatch selected fast validation') + } + elseif ($normalizedLabels -contains 'ci:full' -or $normalizedLabels -contains 'delivery') + { + $changeClass = 'delivery' + $reasons.Add('a delivery label requested the complete pipeline') + } + elseif ($normalizedFiles.Count -eq 0) + { + $changeClass = 'delivery' + $reasons.Add('no changed files were resolved, so the classifier failed safe') + } + else + { + $deliveryFiles = @($normalizedFiles | Where-Object { Test-WinTermDeliveryPath -Path $_ }) + if ($deliveryFiles.Count -gt 0) + { + $changeClass = 'delivery' + $reasons.Add("delivery-sensitive paths changed: $($deliveryFiles -join ', ')") + } + else + { + $nonDocumentationFiles = @($normalizedFiles | Where-Object { -not (Test-WinTermDocumentationPath -Path $_) }) + if ($nonDocumentationFiles.Count -eq 0) + { + $changeClass = 'docs-only' + $reasons.Add('every changed file is in the conservative documentation allowlist') + } + else + { + $changeClass = 'code' + $reasons.Add("non-documentation paths changed: $($nonDocumentationFiles -join ', ')") + } + } + } + + [pscustomobject]@{ + ChangeClass = $changeClass + RunFastBuild = $changeClass -eq 'code' + RunFullBuild = $changeClass -eq 'delivery' + RunPackage = $changeClass -eq 'delivery' + ChangedFiles = $normalizedFiles + Reasons = @($reasons) + } +} + +Export-ModuleMember -Function Get-WinTermChangeClassification, Test-WinTermDeliveryPath, Test-WinTermDocumentationPath diff --git a/scripts/winterm/classify-ci-changes.ps1 b/scripts/winterm/classify-ci-changes.ps1 new file mode 100644 index 000000000..2940247fc --- /dev/null +++ b/scripts/winterm/classify-ci-changes.ps1 @@ -0,0 +1,96 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +try +{ + $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + Import-Module (Join-Path $PSScriptRoot 'ci\ChangeClassification.psm1') -Force + + $mode = if ([string]::IsNullOrWhiteSpace($env:WINTERM_CI_MODE)) { 'auto' } else { $env:WINTERM_CI_MODE.ToLowerInvariant() } + $eventName = if ([string]::IsNullOrWhiteSpace($env:WINTERM_CI_EVENT_NAME)) { 'workflow_dispatch' } else { $env:WINTERM_CI_EVENT_NAME } + $labels = if ([string]::IsNullOrWhiteSpace($env:WINTERM_CI_LABELS_JSON)) { @() } else { @($env:WINTERM_CI_LABELS_JSON | ConvertFrom-Json) } + $changedFiles = @() + $diffDescription = '' + + Set-Location $repositoryRoot + if ($mode -eq 'auto' -and $eventName -eq 'pull_request') + { + if ([string]::IsNullOrWhiteSpace($env:WINTERM_CI_BASE_SHA) -or [string]::IsNullOrWhiteSpace($env:WINTERM_CI_HEAD_SHA)) + { + throw 'The pull request base and head SHAs are required for exact change classification.' + } + $diffDescription = "$env:WINTERM_CI_BASE_SHA...$env:WINTERM_CI_HEAD_SHA" + $changedFiles = @(git diff --name-only --diff-filter=ACMRDTUXB $diffDescription) + if ($LASTEXITCODE -ne 0) + { + throw "git diff failed for '$diffDescription'." + } + } + elseif ($mode -eq 'auto' -and -not [string]::IsNullOrWhiteSpace($env:WINTERM_CI_DEFAULT_BRANCH)) + { + $defaultRef = "origin/$env:WINTERM_CI_DEFAULT_BRANCH" + $headSha = if ([string]::IsNullOrWhiteSpace($env:WINTERM_CI_HEAD_SHA)) { (git rev-parse HEAD).Trim() } else { $env:WINTERM_CI_HEAD_SHA } + $mergeBase = (git merge-base $defaultRef $headSha).Trim() + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($mergeBase) -and $mergeBase -cne $headSha) + { + $diffDescription = "$mergeBase...$headSha" + $changedFiles = @(git diff --name-only --diff-filter=ACMRDTUXB $diffDescription) + if ($LASTEXITCODE -ne 0) + { + throw "git diff failed for '$diffDescription'." + } + } + else + { + $mode = 'fast' + $diffDescription = 'no applicable branch diff; auto selected fast validation' + } + } + + $classification = Get-WinTermChangeClassification -ChangedFiles $changedFiles -Labels $labels -Mode $mode + $outputs = [ordered]@{ + change_class = $classification.ChangeClass + run_fast_build = $classification.RunFastBuild.ToString().ToLowerInvariant() + run_full_build = $classification.RunFullBuild.ToString().ToLowerInvariant() + run_package = $classification.RunPackage.ToString().ToLowerInvariant() + } + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_OUTPUT)) + { + foreach ($entry in $outputs.GetEnumerator()) + { + "$($entry.Key)=$($entry.Value)" >> $env:GITHUB_OUTPUT + } + } + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_STEP_SUMMARY)) + { + @( + '## Change classification', + '', + "- Selected class: **$($classification.ChangeClass)**", + "- Diff: $diffDescription", + "- Changed files: $($classification.ChangedFiles.Count)", + "- Reasons: $($classification.Reasons -join '; ')", + '', + '| Output | Value |', + '| --- | --- |', + ('| `change_class` | `{0}` |' -f $classification.ChangeClass), + ('| `run_fast_build` | `{0}` |' -f $outputs.run_fast_build), + ('| `run_full_build` | `{0}` |' -f $outputs.run_full_build), + ('| `run_package` | `{0}` |' -f $outputs.run_package) + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + } + + Write-Host "Selected CI class '$($classification.ChangeClass)': $($classification.Reasons -join '; ')" -ForegroundColor Green +} +catch +{ + Write-Error "CI change classification failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/winterm/test-ci-classification.ps1 b/scripts/winterm/test-ci-classification.ps1 new file mode 100644 index 000000000..46d443fb9 --- /dev/null +++ b/scripts/winterm/test-ci-classification.ps1 @@ -0,0 +1,73 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-Class +{ + param( + [Parameter(Mandatory)] + [string]$Expected, + + [Parameter(Mandatory)] + [AllowEmptyCollection()] + [string[]]$ChangedFiles, + + [Parameter()] + [string[]]$Labels = @(), + + [Parameter()] + [ValidateSet('auto', 'fast', 'full')] + [string]$Mode = 'auto' + ) + + $actual = Get-WinTermChangeClassification -ChangedFiles $ChangedFiles -Labels $Labels -Mode $Mode + if ($actual.ChangeClass -cne $Expected) + { + throw "Expected '$Expected' for '$($ChangedFiles -join ', ')' but selected '$($actual.ChangeClass)'." + } +} + +try +{ + Import-Module (Join-Path $PSScriptRoot 'ci\ChangeClassification.psm1') -Force + + Assert-Class -Expected 'docs-only' -ChangedFiles @('README.md', 'docs/architecture/progress.md', '.github/ISSUE_TEMPLATE/bug.md') + Assert-Class -Expected 'code' -ChangedFiles @('src/cascadia/TerminalApp/Pane.cpp') + Assert-Class -Expected 'code' -ChangedFiles @('assets/winterm/icons/winterm-32.png') + Assert-Class -Expected 'delivery' -ChangedFiles @('.github/workflows/winterm-validation.yml') + Assert-Class -Expected 'delivery' -ChangedFiles @('src/winterm/Branding/version.json') + Assert-Class -Expected 'delivery' -ChangedFiles @('packaging/inno/winTerm.iss') + Assert-Class -Expected 'delivery' -ChangedFiles @('src/cascadia/TerminalApp/TerminalAppLib.vcxproj') + Assert-Class -Expected 'delivery' -ChangedFiles @('docs/releases/1.1.3.md') + Assert-Class -Expected 'delivery' -ChangedFiles @('README.md') -Labels @('ci:full') + Assert-Class -Expected 'code' -ChangedFiles @() -Mode fast + Assert-Class -Expected 'delivery' -ChangedFiles @() -Mode full + + $docs = Get-WinTermChangeClassification -ChangedFiles @('README.md') + if ($docs.RunFastBuild -or $docs.RunFullBuild -or $docs.RunPackage) + { + throw 'Documentation-only changes must not select native build or package jobs.' + } + $code = Get-WinTermChangeClassification -ChangedFiles @('src/cascadia/TerminalApp/Pane.cpp') + if (-not $code.RunFastBuild -or $code.RunFullBuild -or $code.RunPackage) + { + throw 'Code changes must select only the fast Release build.' + } + $delivery = Get-WinTermChangeClassification -ChangedFiles @('.github/workflows/winterm-validation.yml') + if ($delivery.RunFastBuild -or -not $delivery.RunFullBuild -or -not $delivery.RunPackage) + { + throw 'Delivery changes must select full build and packaging without duplicating the Release-only job.' + } + + Write-Host 'PASS: CI change classification and execution modes.' -ForegroundColor Green +} +catch +{ + Write-Error "CI classification tests failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/winterm/test-release-workflow.ps1 b/scripts/winterm/test-release-workflow.ps1 index 540af991f..a93ab4518 100644 --- a/scripts/winterm/test-release-workflow.ps1 +++ b/scripts/winterm/test-release-workflow.ps1 @@ -13,7 +13,7 @@ try { $workflow = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\release.yml') -Raw $wingetWorkflow = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\winget.yml') -Raw - $fullBuildWorkflow = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\winterm-full-build.yml') -Raw + $validationWorkflow = Get-Content -LiteralPath (Join-Path $repositoryRoot '.github\workflows\winterm-validation.yml') -Raw $wingetGenerator = Get-Content -LiteralPath (Join-Path $repositoryRoot 'scripts\winterm\generate-winget-manifests.ps1') -Raw $releaseGenerator = Get-Content -LiteralPath (Join-Path $repositoryRoot 'scripts\winterm\generate-release-artifacts.ps1') -Raw @@ -84,7 +84,7 @@ try } } - foreach ($workflowToInspect in @($workflow, $wingetWorkflow, $fullBuildWorkflow)) + foreach ($workflowToInspect in @($workflow, $wingetWorkflow, $validationWorkflow)) { foreach ($line in $workflowToInspect -split "`r?`n") { @@ -160,12 +160,43 @@ try 'winTerm-${{ env.WINTERM_VERSION }}-portable-x64.zip' )) { - if (-not $fullBuildWorkflow.Contains($required)) + if (-not $validationWorkflow.Contains($required)) { - throw "Full-build CI is missing required unpackaged distribution gate '$required'." + throw "Classified PR CI is missing required unpackaged distribution gate '$required'." } } + foreach ($required in @( + 'classify-changes:', + 'change_class:', + 'run_fast_build:', + 'run_full_build:', + 'run_package:', + 'ci-gate:', + 'if: always()', + 'cancel-in-progress: true', + 'validation:', + '- auto', + '- fast', + '- full' + )) + { + if (-not $validationWorkflow.Contains($required)) + { + throw "Classified PR CI is missing required boundary '$required'." + } + } + if ($validationWorkflow -match '(?m)^\s*push\s*:' -or + $validationWorkflow -match '(?m)^\s*pull_request_target\s*:' -or + $validationWorkflow -match '(?m)^\s*paths-ignore\s*:') + { + throw 'PR validation contains a duplicate push trigger, pull_request_target, or trigger-level path exclusion.' + } + if (Test-Path -LiteralPath (Join-Path $repositoryRoot '.github\workflows\winterm-full-build.yml')) + { + throw 'The retired duplicate full-build PR workflow still exists.' + } + Write-Host 'PASS: unpackaged release and WinGet workflow security and publication boundaries.' -ForegroundColor Green } catch diff --git a/scripts/winterm/test.ps1 b/scripts/winterm/test.ps1 index af560b39c..92492629e 100644 --- a/scripts/winterm/test.ps1 +++ b/scripts/winterm/test.ps1 @@ -220,6 +220,12 @@ try throw 'Release workflow validation failed.' } + & (Join-Path $PSScriptRoot 'test-ci-classification.ps1') + if (-not $?) + { + throw 'CI change classification validation failed.' + } + & (Join-Path $PSScriptRoot 'validate-assets.ps1') if (-not $?) { From 809c5fa842bcf5be366d113b67fa44bc56426012 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 02:47:38 +0800 Subject: [PATCH 2/4] feat: add normalized visual progress core --- .../TerminalApp/TerminalPaneContent.cpp | 5 + .../TerminalApp/TerminalPaneContent.h | 5 + .../TerminalApp/TerminalPaneContent.idl | 4 + src/cascadia/TerminalControl/ControlCore.cpp | 20 ++ src/cascadia/TerminalControl/ControlCore.h | 4 + src/cascadia/TerminalControl/ControlCore.idl | 1 + src/cascadia/TerminalControl/ICoreState.idl | 2 + src/cascadia/TerminalControl/TermControl.cpp | 11 + src/cascadia/TerminalControl/TermControl.h | 4 + src/cascadia/TerminalControl/TermControl.idl | 1 + src/cascadia/TerminalCore/Terminal.cpp | 15 + src/cascadia/TerminalCore/Terminal.hpp | 8 +- src/cascadia/TerminalCore/TerminalApi.cpp | 11 +- .../GlobalAppSettings.idl | 1 + .../TerminalSettingsModel/MTSMSettings.h | 3 +- .../SettingsModel.UnitTests.vcxproj | 1 + .../WinTermVisualProgressTests.cpp | 229 +++++++++++++++ src/host/outputStream.cpp | 3 +- src/host/outputStream.hpp | 2 +- src/terminal/adapter/ITerminalApi.hpp | 19 +- src/terminal/adapter/adaptDispatch.cpp | 12 +- .../adapter/ut_adapter/adapterTest.cpp | 2 +- .../VisualProgress/VisualProgressModel.h | 276 ++++++++++++++++++ 23 files changed, 622 insertions(+), 17 deletions(-) create mode 100644 src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp create mode 100644 src/winterm/VisualProgress/VisualProgressModel.h diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.cpp b/src/cascadia/TerminalApp/TerminalPaneContent.cpp index b7000d9c2..4a3d6701b 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.cpp +++ b/src/cascadia/TerminalApp/TerminalPaneContent.cpp @@ -40,6 +40,7 @@ namespace winrt::TerminalApp::implementation _controlEvents._TitleChanged = _control.TitleChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlTitleChanged }); _controlEvents._TabColorChanged = _control.TabColorChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlTabColorChanged }); _controlEvents._SetTaskbarProgress = _control.SetTaskbarProgress(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlSetTaskbarProgress }); + _controlEvents._ShellIntegrationChanged = _control.ShellIntegrationChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlShellIntegrationChanged }); _controlEvents._ReadOnlyChanged = _control.ReadOnlyChanged(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlReadOnlyChanged }); _controlEvents._FocusFollowMouseRequested = _control.FocusFollowMouseRequested(winrt::auto_revoke, { get_weak(), &TerminalPaneContent::_controlFocusFollowMouseRequested }); } @@ -172,6 +173,10 @@ namespace winrt::TerminalApp::implementation void TerminalPaneContent::_controlSetTaskbarProgress(const IInspectable&, const IInspectable&) { TaskbarProgressChanged.raise(*this, nullptr); + } + void TerminalPaneContent::_controlShellIntegrationChanged(const IInspectable&, const IInspectable&) + { + ShellIntegrationChanged.raise(*this, nullptr); } void TerminalPaneContent::_controlReadOnlyChanged(const IInspectable&, const IInspectable&) { diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.h b/src/cascadia/TerminalApp/TerminalPaneContent.h index 0e828cdc1..995815a26 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.h +++ b/src/cascadia/TerminalApp/TerminalPaneContent.h @@ -45,6 +45,8 @@ namespace winrt::TerminalApp::implementation winrt::hstring Title() { return _control.Title(); } uint64_t TaskbarState() { return _control.TaskbarState(); } uint64_t TaskbarProgress() { return _control.TaskbarProgress(); } + uint64_t ShellIntegrationState() { return _control.ShellIntegrationState(); } + int64_t ShellIntegrationExitCode() { return _control.ShellIntegrationExitCode(); } bool ReadOnly() { return _control.ReadOnly(); } winrt::hstring Icon() const; Windows::Foundation::IReference TabColor() const noexcept; @@ -54,6 +56,7 @@ namespace winrt::TerminalApp::implementation Windows::Foundation::Size GridUnitSize(); til::typed_event RestartTerminalRequested; + til::typed_event ShellIntegrationChanged; // See BasicPaneEvents for most generic event definitions @@ -77,6 +80,7 @@ namespace winrt::TerminalApp::implementation winrt::Microsoft::Terminal::Control::TermControl::TitleChanged_revoker _TitleChanged; winrt::Microsoft::Terminal::Control::TermControl::TabColorChanged_revoker _TabColorChanged; winrt::Microsoft::Terminal::Control::TermControl::SetTaskbarProgress_revoker _SetTaskbarProgress; + winrt::Microsoft::Terminal::Control::TermControl::ShellIntegrationChanged_revoker _ShellIntegrationChanged; winrt::Microsoft::Terminal::Control::TermControl::ReadOnlyChanged_revoker _ReadOnlyChanged; winrt::Microsoft::Terminal::Control::TermControl::FocusFollowMouseRequested_revoker _FocusFollowMouseRequested; @@ -94,6 +98,7 @@ namespace winrt::TerminalApp::implementation void _controlTitleChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlTabColorChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlSetTaskbarProgress(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); + void _controlShellIntegrationChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlReadOnlyChanged(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); void _controlFocusFollowMouseRequested(const winrt::Windows::Foundation::IInspectable& sender, const winrt::Windows::Foundation::IInspectable& args); diff --git a/src/cascadia/TerminalApp/TerminalPaneContent.idl b/src/cascadia/TerminalApp/TerminalPaneContent.idl index 53dcf4c33..a4985242d 100644 --- a/src/cascadia/TerminalApp/TerminalPaneContent.idl +++ b/src/cascadia/TerminalApp/TerminalPaneContent.idl @@ -9,6 +9,10 @@ namespace TerminalApp [default_interface] runtimeclass TerminalPaneContent : IPaneContent, ISnappable { Microsoft.Terminal.Control.TermControl GetTermControl(); + + UInt64 ShellIntegrationState { get; }; + Int64 ShellIntegrationExitCode { get; }; + event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; void MarkAsDefterm(); diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index a68eaa24b..6e1789fec 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -126,6 +126,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation auto pfnTerminalTaskbarProgressChanged = [this] { _terminalTaskbarProgressChanged(); }; _terminal->TaskbarProgressChangedCallback(pfnTerminalTaskbarProgressChanged); + + auto pfnTerminalShellIntegrationChanged = [this] { _terminalShellIntegrationChanged(); }; + _terminal->ShellIntegrationChangedCallback(pfnTerminalShellIntegrationChanged); auto pfnShowWindowChanged = [this](auto&& PH1) { _terminalShowWindowChanged(std::forward(PH1)); }; _terminal->SetShowWindowCallback(pfnShowWindowChanged); @@ -1580,6 +1583,18 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _terminal->GetTaskbarProgress(); } + const size_t ControlCore::ShellIntegrationState() const noexcept + { + const auto lock = _terminal->LockForReading(); + return _terminal->GetShellIntegrationState(); + } + + const int64_t ControlCore::ShellIntegrationExitCode() const noexcept + { + const auto lock = _terminal->LockForReading(); + return _terminal->GetShellIntegrationExitCode(); + } + int ControlCore::ScrollOffset() { const auto lock = _terminal->LockForReading(); @@ -1674,6 +1689,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation void ControlCore::_terminalTaskbarProgressChanged() { TaskbarProgressChanged.raise(*this, nullptr); + } + + void ControlCore::_terminalShellIntegrationChanged() + { + ShellIntegrationChanged.raise(*this, nullptr); } void ControlCore::_terminalShowWindowChanged(bool showOrHide) diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index 9cd8b999b..922f9783d 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -163,6 +163,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation #pragma region ICoreState const size_t TaskbarState() const noexcept; const size_t TaskbarProgress() const noexcept; + const size_t ShellIntegrationState() const noexcept; + const int64_t ShellIntegrationExitCode() const noexcept; hstring Title(); Windows::Foundation::IReference TabColor() noexcept; @@ -281,6 +283,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation til::typed_event<> BackgroundColorChanged; til::typed_event ScrollPositionChanged; til::typed_event<> TaskbarProgressChanged; + til::typed_event<> ShellIntegrationChanged; til::typed_event<> ConnectionStateChanged; til::typed_event<> HoveredHyperlinkChanged; til::typed_event RendererEnteredErrorState; @@ -330,6 +333,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation const int viewHeight, const int bufferSize); void _terminalTaskbarProgressChanged(); + void _terminalShellIntegrationChanged(); void _terminalShowWindowChanged(bool showOrHide); void _terminalPlayMidiNote(const int noteNumber, const int velocity, diff --git a/src/cascadia/TerminalControl/ControlCore.idl b/src/cascadia/TerminalControl/ControlCore.idl index 31bc24d59..c0abfab23 100644 --- a/src/cascadia/TerminalControl/ControlCore.idl +++ b/src/cascadia/TerminalControl/ControlCore.idl @@ -195,6 +195,7 @@ namespace Microsoft.Terminal.Control event Windows.Foundation.TypedEventHandler TabColorChanged; event Windows.Foundation.TypedEventHandler BackgroundColorChanged; event Windows.Foundation.TypedEventHandler TaskbarProgressChanged; + event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; event Windows.Foundation.TypedEventHandler RendererEnteredErrorState; event Windows.Foundation.TypedEventHandler ShowWindowChanged; event Windows.Foundation.TypedEventHandler SearchMissingCommand; diff --git a/src/cascadia/TerminalControl/ICoreState.idl b/src/cascadia/TerminalControl/ICoreState.idl index 38f0cea30..2c28bdd53 100644 --- a/src/cascadia/TerminalControl/ICoreState.idl +++ b/src/cascadia/TerminalControl/ICoreState.idl @@ -33,6 +33,8 @@ namespace Microsoft.Terminal.Control String Title { get; }; UInt64 TaskbarState { get; }; UInt64 TaskbarProgress { get; }; + UInt64 ShellIntegrationState { get; }; + Int64 ShellIntegrationExitCode { get; }; String WorkingDirectory { get; }; diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 2ea1ff9b2..485bd67dc 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -322,6 +322,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation _revokers.TitleChanged = _core.TitleChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleTitleChanged }); _revokers.TabColorChanged = _core.TabColorChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleTabColorChanged }); _revokers.TaskbarProgressChanged = _core.TaskbarProgressChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleSetTaskbarProgress }); + _revokers.ShellIntegrationChanged = _core.ShellIntegrationChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleShellIntegrationChanged }); _revokers.ConnectionStateChanged = _core.ConnectionStateChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleConnectionStateChanged }); _revokers.ShowWindowChanged = _core.ShowWindowChanged(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleShowWindowChanged }); _revokers.CloseTerminalRequested = _core.CloseTerminalRequested(winrt::auto_revoke, { get_weak(), &TermControl::_bubbleCloseTerminalRequested }); @@ -3367,6 +3368,16 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _core.TaskbarProgress(); } + const uint64_t TermControl::ShellIntegrationState() const noexcept + { + return _core.ShellIntegrationState(); + } + + const int64_t TermControl::ShellIntegrationExitCode() const noexcept + { + return _core.ShellIntegrationExitCode(); + } + void TermControl::BellLightOn() { // Initialize the animation if it does not exist diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index dca62b29b..0497999f4 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -88,6 +88,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation #pragma region ICoreState const uint64_t TaskbarState() const noexcept; const uint64_t TaskbarProgress() const noexcept; + const uint64_t ShellIntegrationState() const noexcept; + const int64_t ShellIntegrationExitCode() const noexcept; hstring Title(); Windows::Foundation::IReference TabColor() noexcept; @@ -225,6 +227,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation BUBBLED_FORWARDED_TYPED_EVENT(TitleChanged, IInspectable, Control::TitleChangedEventArgs); BUBBLED_FORWARDED_TYPED_EVENT(TabColorChanged, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(SetTaskbarProgress, IInspectable, IInspectable); + BUBBLED_FORWARDED_TYPED_EVENT(ShellIntegrationChanged, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(ConnectionStateChanged, IInspectable, IInspectable); BUBBLED_FORWARDED_TYPED_EVENT(ShowWindowChanged, IInspectable, Control::ShowWindowArgs); BUBBLED_FORWARDED_TYPED_EVENT(CloseTerminalRequested, IInspectable, IInspectable); @@ -464,6 +467,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation Control::ControlCore::WriteToClipboard_revoker WriteToClipboard; Control::ControlCore::TabColorChanged_revoker TabColorChanged; Control::ControlCore::TaskbarProgressChanged_revoker TaskbarProgressChanged; + Control::ControlCore::ShellIntegrationChanged_revoker ShellIntegrationChanged; Control::ControlCore::ConnectionStateChanged_revoker ConnectionStateChanged; Control::ControlCore::ShowWindowChanged_revoker ShowWindowChanged; Control::ControlCore::CloseTerminalRequested_revoker CloseTerminalRequested; diff --git a/src/cascadia/TerminalControl/TermControl.idl b/src/cascadia/TerminalControl/TermControl.idl index 54ded1a0f..3ce9d1cf7 100644 --- a/src/cascadia/TerminalControl/TermControl.idl +++ b/src/cascadia/TerminalControl/TermControl.idl @@ -67,6 +67,7 @@ namespace Microsoft.Terminal.Control event Windows.Foundation.TypedEventHandler PasteFromClipboard; event Windows.Foundation.TypedEventHandler OpenHyperlink; event Windows.Foundation.TypedEventHandler SetTaskbarProgress; + event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; event Windows.Foundation.TypedEventHandler RaiseNotice; event Windows.Foundation.TypedEventHandler WarningBell; event Windows.Foundation.TypedEventHandler HidePointerCursor; diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index 756ca2cef..e8274c625 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -1174,6 +1174,11 @@ void Microsoft::Terminal::Core::Terminal::TaskbarProgressChangedCallback(std::fu _pfnTaskbarProgressChanged.swap(pfn); } +void Microsoft::Terminal::Core::Terminal::ShellIntegrationChangedCallback(std::function pfn) noexcept +{ + _pfnShellIntegrationChanged.swap(pfn); +} + // Method Description: // - Propagates an incoming set window visibility call from the PTY up into our window control layers // Arguments: @@ -1264,6 +1269,16 @@ const size_t Microsoft::Terminal::Core::Terminal::GetTaskbarState() const noexce const size_t Microsoft::Terminal::Core::Terminal::GetTaskbarProgress() const noexcept { return _taskbarProgress; +} + +const size_t Microsoft::Terminal::Core::Terminal::GetShellIntegrationState() const noexcept +{ + return _shellIntegrationState; +} + +const int64_t Microsoft::Terminal::Core::Terminal::GetShellIntegrationExitCode() const noexcept +{ + return _shellIntegrationExitCode; } void Microsoft::Terminal::Core::Terminal::CompletionsChangedCallback(std::function pfn) noexcept diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index f3330544f..6c5114b4e 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -159,7 +159,7 @@ class Microsoft::Terminal::Core::Terminal final : bool IsVtInputEnabled() const noexcept override; void NotifyBufferRotation(const int delta) override; - void NotifyShellIntegrationMark() override; + void NotifyShellIntegrationMark(::Microsoft::Console::VirtualTerminal::ShellIntegrationMarkKind kind, std::optional exitCode) override; void InvokeCompletions(std::wstring_view menuJson, unsigned int replaceLength) override; @@ -229,6 +229,7 @@ class Microsoft::Terminal::Core::Terminal final : void SetCopyToClipboardCallback(std::function pfn) noexcept; void SetScrollPositionChangedCallback(std::function pfn) noexcept; void TaskbarProgressChangedCallback(std::function pfn) noexcept; + void ShellIntegrationChangedCallback(std::function pfn) noexcept; void SetShowWindowCallback(std::function pfn) noexcept; void SetPlayMidiNoteCallback(std::function pfn) noexcept; void CompletionsChangedCallback(std::function pfn) noexcept; @@ -245,6 +246,8 @@ class Microsoft::Terminal::Core::Terminal final : const size_t GetTaskbarState() const noexcept; const size_t GetTaskbarProgress() const noexcept; + const size_t GetShellIntegrationState() const noexcept; + const int64_t GetShellIntegrationExitCode() const noexcept; void ColorSelection(const TextAttribute& attr, winrt::Microsoft::Terminal::Core::MatchMode matchMode); void PreviewText(std::wstring_view input); @@ -337,6 +340,7 @@ class Microsoft::Terminal::Core::Terminal final : std::function _pfnScrollPositionChanged; std::function _pfnTaskbarProgressChanged; + std::function _pfnShellIntegrationChanged; std::function _pfnShowWindowChanged; std::function _pfnPlayMidiNote; std::function _pfnCompletionsChanged; @@ -373,6 +377,8 @@ class Microsoft::Terminal::Core::Terminal final : size_t _taskbarState = 0; size_t _taskbarProgress = 0; + size_t _shellIntegrationState = 0; + int64_t _shellIntegrationExitCode = -1; size_t _hyperlinkPatternId = 0; diff --git a/src/cascadia/TerminalCore/TerminalApi.cpp b/src/cascadia/TerminalCore/TerminalApi.cpp index 3030e3b8b..d51279fc6 100644 --- a/src/cascadia/TerminalCore/TerminalApi.cpp +++ b/src/cascadia/TerminalCore/TerminalApi.cpp @@ -416,8 +416,17 @@ void Terminal::NotifyBufferRotation(const int delta) } } -void Terminal::NotifyShellIntegrationMark() +void Terminal::NotifyShellIntegrationMark(const ::Microsoft::Console::VirtualTerminal::ShellIntegrationMarkKind kind, + const std::optional exitCode) { + _assertLocked(); + _shellIntegrationState = static_cast(kind); + _shellIntegrationExitCode = exitCode ? static_cast(*exitCode) : -1; + // Notify the scrollbar that marks have been added so it can refresh the mark indicators _NotifyScrollEvent(); + if (_pfnShellIntegrationChanged) + { + _pfnShellIntegrationChanged(); + } } diff --git a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl index 7d38cdf18..4209e150d 100644 --- a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl +++ b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl @@ -131,6 +131,7 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_SETTING(Boolean, ShowPaneHeaders); INHERITABLE_SETTING(Boolean, ShowPaneProfileIcon); INHERITABLE_SETTING(Boolean, ShowPaneActiveStatus); + INHERITABLE_SETTING(Boolean, VisualProgressEnabled); Windows.Foundation.Collections.IMapView ColorSchemes(); void AddColorScheme(ColorScheme scheme); diff --git a/src/cascadia/TerminalSettingsModel/MTSMSettings.h b/src/cascadia/TerminalSettingsModel/MTSMSettings.h index 89e9809b4..2f7aa0324 100644 --- a/src/cascadia/TerminalSettingsModel/MTSMSettings.h +++ b/src/cascadia/TerminalSettingsModel/MTSMSettings.h @@ -88,7 +88,8 @@ Author(s): X(Model::ApplicationUIDensity, ApplicationUIDensity, "winterm.applicationUI.density", Model::ApplicationUIDensity::Compact) \ X(bool, ShowPaneHeaders, "winterm.applicationUI.showPaneHeaders", true) \ X(bool, ShowPaneProfileIcon, "winterm.applicationUI.showPaneProfileIcon", true) \ - X(bool, ShowPaneActiveStatus, "winterm.applicationUI.showPaneActiveStatus", true) + X(bool, ShowPaneActiveStatus, "winterm.applicationUI.showPaneActiveStatus", true) \ + X(bool, VisualProgressEnabled, "visualProgress.enabled", false) // Also add these settings to: // * Profile.idl diff --git a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj index c16bc35a6..dd3b87a37 100644 --- a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj +++ b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj @@ -50,6 +50,7 @@ + diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp new file mode 100644 index 000000000..c8ef6714c --- /dev/null +++ b/src/cascadia/UnitTests_SettingsModel/WinTermVisualProgressTests.cpp @@ -0,0 +1,229 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" + +#include "../TerminalSettingsModel/GlobalAppSettings.h" +#include "../../winterm/VisualProgress/VisualProgressModel.h" + +using namespace WEX::TestExecution; +using namespace winTerm::VisualProgress; +using namespace winrt::Microsoft::Terminal::Settings::Model; + +namespace SettingsModelUnitTests +{ + class WinTermVisualProgressTests + { + TEST_CLASS(WinTermVisualProgressTests); + + TEST_METHOD(MapEveryTaskbarState); + TEST_METHOD(ClampDeterminateValues); + TEST_METHOD(SuppressDuplicateState); + TEST_METHOD(StandardProgressPrecedesShellLifecycle); + TEST_METHOD(CommandCompletionClearsAtNextPrompt); + TEST_METHOD(EmergencyOverridePrecedesSetting); + TEST_METHOD(DisabledFeatureIgnoresEvents); + TEST_METHOD(MultiplePanesRemainIndependent); + TEST_METHOD(CloseAndDetachCleanupStopsUpdates); + TEST_METHOD(SplitOrDetachResetClearsReusableState); + TEST_METHOD(FeatureReloadDisablesAndReenablesCleanly); + TEST_METHOD(MailboxCoalescesRapidUpdatesAndReleasesOnClose); + TEST_METHOD(UnexpectedEventsFailOpen); + TEST_METHOD(SettingSerializesAndMissingSettingDefaultsOff); + }; + + void WinTermVisualProgressTests::MapEveryTaskbarState() + { + ProgressStateMachine state; + state.SetEnabled(true); + + auto snapshot = state.ApplyTaskbar(1, 50); + VERIFY_IS_TRUE(snapshot.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Determinate), static_cast(snapshot->mode)); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Running), static_cast(snapshot->status)); + VERIFY_ARE_EQUAL(uint8_t{ 50 }, snapshot->value); + + snapshot = state.ApplyTaskbar(3, 0); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(snapshot->mode)); + + snapshot = state.ApplyTaskbar(4, 27); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Waiting), static_cast(snapshot->status)); + VERIFY_ARE_EQUAL(uint8_t{ 27 }, snapshot->value); + + snapshot = state.ApplyTaskbar(2, 0); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(snapshot->status)); + VERIFY_ARE_EQUAL(uint8_t{ 27 }, snapshot->value); + + snapshot = state.ApplyTaskbar(0, 0); + VERIFY_IS_TRUE(snapshot.has_value()); + VERIFY_IS_FALSE(snapshot->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(snapshot->mode)); + } + + void WinTermVisualProgressTests::ClampDeterminateValues() + { + for (const auto value : { uint64_t{ 0 }, uint64_t{ 1 }, uint64_t{ 50 }, uint64_t{ 99 }, uint64_t{ 100 }, uint64_t{ 101 }, uint64_t{ 1000 } }) + { + ProgressStateMachine state; + state.SetEnabled(true); + const auto snapshot = state.ApplyTaskbar(1, value); + VERIFY_IS_TRUE(snapshot.has_value()); + VERIFY_ARE_EQUAL(static_cast(std::min(value, 100)), snapshot->value); + } + } + + void WinTermVisualProgressTests::SuppressDuplicateState() + { + ProgressStateMachine state; + state.SetEnabled(true); + VERIFY_IS_TRUE(state.ApplyTaskbar(1, 50).has_value()); + VERIFY_IS_FALSE(state.ApplyTaskbar(1, 50).has_value()); + VERIFY_IS_TRUE(state.ApplyTaskbar(1, 51).has_value()); + } + + void WinTermVisualProgressTests::StandardProgressPrecedesShellLifecycle() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + const auto explicitProgress = state.ApplyTaskbar(1, 40); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::Taskbar), static_cast(explicitProgress->source)); + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandFinished, 1).has_value()); + + const auto fallback = state.ApplyTaskbar(0, 0); + VERIFY_IS_TRUE(fallback.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressSource::ShellIntegration), static_cast(fallback->source)); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Error), static_cast(fallback->status)); + } + + void WinTermVisualProgressTests::CommandCompletionClearsAtNextPrompt() + { + ProgressStateMachine state; + state.SetEnabled(true); + auto snapshot = state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(snapshot->mode)); + + snapshot = state.ApplyShellLifecycle(ShellLifecycleState::CommandFinished, 0); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Success), static_cast(snapshot->status)); + VERIFY_ARE_EQUAL(uint8_t{ 100 }, snapshot->value); + + snapshot = state.ApplyShellLifecycle(ShellLifecycleState::Prompt, -1); + VERIFY_IS_FALSE(snapshot->visible); + } + + void WinTermVisualProgressTests::EmergencyOverridePrecedesSetting() + { + VERIFY_IS_TRUE(IsFeatureEnabled(true, L"")); + VERIFY_IS_TRUE(IsFeatureEnabled(true, L"0")); + VERIFY_IS_FALSE(IsFeatureEnabled(true, L"1")); + VERIFY_IS_FALSE(IsFeatureEnabled(false, L"")); + } + + void WinTermVisualProgressTests::DisabledFeatureIgnoresEvents() + { + ProgressStateMachine state; + VERIFY_IS_FALSE(state.ApplyTaskbar(1, 50).has_value()); + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1).has_value()); + VERIFY_IS_FALSE(state.Current().visible); + } + + void WinTermVisualProgressTests::MultiplePanesRemainIndependent() + { + ProgressStateMachine first; + ProgressStateMachine second; + first.SetEnabled(true); + second.SetEnabled(true); + first.ApplyTaskbar(1, 25); + second.ApplyTaskbar(1, 75); + VERIFY_ARE_EQUAL(uint8_t{ 25 }, first.Current().value); + VERIFY_ARE_EQUAL(uint8_t{ 75 }, second.Current().value); + } + + void WinTermVisualProgressTests::CloseAndDetachCleanupStopsUpdates() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyTaskbar(1, 25); + const auto closed = state.Close(); + VERIFY_IS_TRUE(closed.has_value()); + VERIFY_IS_FALSE(closed->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressStatus::Cancelled), static_cast(closed->status)); + VERIFY_IS_FALSE(state.ApplyTaskbar(1, 75).has_value()); + VERIFY_IS_FALSE(state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1).has_value()); + } + + void WinTermVisualProgressTests::SplitOrDetachResetClearsReusableState() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyTaskbar(1, 25); + + const auto reset = state.Reset(); + VERIFY_IS_TRUE(reset.has_value()); + VERIFY_IS_FALSE(reset->visible); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Hidden), static_cast(reset->mode)); + + const auto reused = state.ApplyShellLifecycle(ShellLifecycleState::CommandStart, -1); + VERIFY_IS_TRUE(reused.has_value()); + VERIFY_ARE_EQUAL(static_cast(ProgressMode::Indeterminate), static_cast(reused->mode)); + } + + void WinTermVisualProgressTests::FeatureReloadDisablesAndReenablesCleanly() + { + ProgressStateMachine state; + state.SetEnabled(true); + state.ApplyTaskbar(1, 50); + + const auto disabled = state.SetEnabled(false); + VERIFY_IS_TRUE(disabled.has_value()); + VERIFY_IS_FALSE(disabled->visible); + VERIFY_IS_FALSE(state.ApplyTaskbar(1, 75).has_value()); + + state.SetEnabled(true); + const auto reenabled = state.ApplyTaskbar(1, 75); + VERIFY_IS_TRUE(reenabled.has_value()); + VERIFY_ARE_EQUAL(uint8_t{ 75 }, reenabled->value); + } + + void WinTermVisualProgressTests::MailboxCoalescesRapidUpdatesAndReleasesOnClose() + { + ProgressUpdateMailbox mailbox; + bool allPublished = true; + for (uint64_t value = 0; value < 10000; ++value) + { + allPublished = mailbox.Publish({ ProgressMode::Determinate, ProgressStatus::Running, static_cast(value % 101), true, ProgressSource::Taskbar, value }) && allPublished; + } + VERIFY_IS_TRUE(allPublished); + const auto latest = mailbox.TakeLatest(); + VERIFY_IS_TRUE(latest.has_value()); + VERIFY_ARE_EQUAL(uint64_t{ 9999 }, latest->sequence); + VERIFY_IS_FALSE(mailbox.HasPending()); + + mailbox.Close(); + VERIFY_IS_FALSE(mailbox.Publish({ ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::Taskbar, 10000 })); + VERIFY_IS_FALSE(mailbox.TakeLatest().has_value()); + } + + void WinTermVisualProgressTests::UnexpectedEventsFailOpen() + { + ProgressStateMachine state; + state.SetEnabled(true); + VERIFY_IS_FALSE(state.ApplyTaskbar(99, UINT64_MAX).has_value()); + VERIFY_IS_FALSE(state.ApplyShellLifecycle(static_cast(99), INT64_MAX).has_value()); + VERIFY_IS_FALSE(state.Current().visible); + } + + void WinTermVisualProgressTests::SettingSerializesAndMissingSettingDefaultsOff() + { + Json::Value enabledJson{ Json::objectValue }; + enabledJson["visualProgress.enabled"] = true; + const auto enabled = winrt::Microsoft::Terminal::Settings::Model::implementation::GlobalAppSettings::FromJson(enabledJson); + VERIFY_IS_TRUE(enabled->VisualProgressEnabled()); + VERIFY_IS_TRUE(enabled->ToJson()["visualProgress.enabled"].asBool()); + + Json::Value legacyJson{ Json::objectValue }; + const auto migrated = winrt::Microsoft::Terminal::Settings::Model::implementation::GlobalAppSettings::FromJson(legacyJson); + VERIFY_IS_FALSE(migrated->VisualProgressEnabled()); + VERIFY_IS_FALSE(migrated->ToJson().isMember("visualProgress.enabled")); + } +} diff --git a/src/host/outputStream.cpp b/src/host/outputStream.cpp index 3852f8a8f..c67dd6030 100644 --- a/src/host/outputStream.cpp +++ b/src/host/outputStream.cpp @@ -446,7 +446,8 @@ void ConhostInternalGetSet::NotifyBufferRotation(const int) { } -void ConhostInternalGetSet::NotifyShellIntegrationMark() +void ConhostInternalGetSet::NotifyShellIntegrationMark(const ::Microsoft::Console::VirtualTerminal::ShellIntegrationMarkKind /*kind*/, + const std::optional /*exitCode*/) { // Not implemented for conhost - shell integration marks are a Terminal app feature. } diff --git a/src/host/outputStream.hpp b/src/host/outputStream.hpp index 2591e4304..9d9ee88ad 100644 --- a/src/host/outputStream.hpp +++ b/src/host/outputStream.hpp @@ -68,7 +68,7 @@ class ConhostInternalGetSet final : public Microsoft::Console::VirtualTerminal:: bool IsVtInputEnabled() const override; void NotifyBufferRotation(const int delta) override; - void NotifyShellIntegrationMark() override; + void NotifyShellIntegrationMark(::Microsoft::Console::VirtualTerminal::ShellIntegrationMarkKind kind, std::optional exitCode) override; void InvokeCompletions(std::wstring_view menuJson, unsigned int replaceLength) override; diff --git a/src/terminal/adapter/ITerminalApi.hpp b/src/terminal/adapter/ITerminalApi.hpp index cb67bd1f6..9fee0b2c4 100644 --- a/src/terminal/adapter/ITerminalApi.hpp +++ b/src/terminal/adapter/ITerminalApi.hpp @@ -20,11 +20,20 @@ Author(s): #include "../../buffer/out/textBuffer.hpp" #include "../../renderer/inc/RenderSettings.hpp" -#include -#include - -namespace Microsoft::Console::VirtualTerminal +#include +#include +#include + +namespace Microsoft::Console::VirtualTerminal { + enum class ShellIntegrationMarkKind : uint8_t + { + Prompt = 1, + CommandStart = 2, + CommandExecuted = 3, + CommandFinished = 4, + }; + class ITerminalApi { using RenderSettings = Microsoft::Console::Render::RenderSettings; @@ -87,7 +96,7 @@ namespace Microsoft::Console::VirtualTerminal virtual bool ResizeWindow(const til::CoordType width, const til::CoordType height) = 0; virtual void NotifyBufferRotation(const int delta) = 0; - virtual void NotifyShellIntegrationMark() = 0; + virtual void NotifyShellIntegrationMark(ShellIntegrationMarkKind kind, std::optional exitCode = std::nullopt) = 0; virtual void InvokeCompletions(std::wstring_view menuJson, unsigned int replaceLength) = 0; diff --git a/src/terminal/adapter/adaptDispatch.cpp b/src/terminal/adapter/adaptDispatch.cpp index 13aa74cc4..c40f1dda9 100644 --- a/src/terminal/adapter/adaptDispatch.cpp +++ b/src/terminal/adapter/adaptDispatch.cpp @@ -3619,7 +3619,7 @@ void AdaptDispatch::DoConEmuAction(const std::wstring_view string) else if (subParam == 12) { _pages.ActivePage().Buffer().StartCommand(); - _api.NotifyShellIntegrationMark(); + _api.NotifyShellIntegrationMark(ShellIntegrationMarkKind::CommandStart); } else { @@ -3654,7 +3654,7 @@ void AdaptDispatch::DoITerm2Action(const std::wstring_view string) if (action == L"SetMark") { _pages.ActivePage().Buffer().StartPrompt(); - _api.NotifyShellIntegrationMark(); + _api.NotifyShellIntegrationMark(ShellIntegrationMarkKind::Prompt); } else { @@ -3692,19 +3692,19 @@ void AdaptDispatch::DoFinalTermAction(const std::wstring_view string) case L'A': // FTCS_PROMPT { _pages.ActivePage().Buffer().StartPrompt(); - _api.NotifyShellIntegrationMark(); + _api.NotifyShellIntegrationMark(ShellIntegrationMarkKind::Prompt); break; } case L'B': // FTCS_COMMAND_START { _pages.ActivePage().Buffer().StartCommand(); - _api.NotifyShellIntegrationMark(); + _api.NotifyShellIntegrationMark(ShellIntegrationMarkKind::CommandStart); break; } case L'C': // FTCS_COMMAND_EXECUTED { _pages.ActivePage().Buffer().StartOutput(); - _api.NotifyShellIntegrationMark(); + _api.NotifyShellIntegrationMark(ShellIntegrationMarkKind::CommandExecuted); break; } case L'D': // FTCS_COMMAND_FINISHED @@ -3725,7 +3725,7 @@ void AdaptDispatch::DoFinalTermAction(const std::wstring_view string) } _pages.ActivePage().Buffer().EndCurrentCommand(error); - _api.NotifyShellIntegrationMark(); + _api.NotifyShellIntegrationMark(ShellIntegrationMarkKind::CommandFinished, error); break; } diff --git a/src/terminal/adapter/ut_adapter/adapterTest.cpp b/src/terminal/adapter/ut_adapter/adapterTest.cpp index 7da1782fd..1efe59ac5 100644 --- a/src/terminal/adapter/ut_adapter/adapterTest.cpp +++ b/src/terminal/adapter/ut_adapter/adapterTest.cpp @@ -216,7 +216,7 @@ class TestGetSet final : public ITerminalApi Log::Comment(L"NotifyBufferRotation MOCK called..."); } - void NotifyShellIntegrationMark() override + void NotifyShellIntegrationMark(const ShellIntegrationMarkKind /*kind*/, const std::optional /*exitCode*/) override { Log::Comment(L"NotifyShellIntegrationMark MOCK called..."); } diff --git a/src/winterm/VisualProgress/VisualProgressModel.h b/src/winterm/VisualProgress/VisualProgressModel.h new file mode 100644 index 000000000..1e861c03b --- /dev/null +++ b/src/winterm/VisualProgress/VisualProgressModel.h @@ -0,0 +1,276 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#pragma once + +#include +#include +#include +#include +#include + +namespace winTerm::VisualProgress +{ + enum class ProgressMode : uint8_t + { + Hidden, + Determinate, + Indeterminate, + }; + + enum class ProgressStatus : uint8_t + { + Running, + Waiting, + Success, + Error, + Cancelled, + }; + + enum class ProgressSource : uint8_t + { + None, + Taskbar, + ShellIntegration, + }; + + // Values intentionally mirror the semantic state exposed by TerminalCore. + enum class ShellLifecycleState : uint8_t + { + None = 0, + Prompt = 1, + CommandStart = 2, + CommandExecuted = 3, + CommandFinished = 4, + }; + + struct ProgressSnapshot + { + ProgressMode mode{ ProgressMode::Hidden }; + ProgressStatus status{ ProgressStatus::Cancelled }; + uint8_t value{}; + bool visible{}; + ProgressSource source{ ProgressSource::None }; + uint64_t sequence{}; + + bool SamePresentation(const ProgressSnapshot& other) const noexcept + { + return mode == other.mode && + status == other.status && + value == other.value && + visible == other.visible && + source == other.source; + } + }; + + inline bool IsFeatureEnabled(const bool settingEnabled, const std::wstring_view emergencyOverride) noexcept + { + return settingEnabled && emergencyOverride != L"1"; + } + + class ProgressStateMachine final + { + public: + std::optional SetEnabled(const bool enabled) noexcept + { + std::scoped_lock lock{ _mutex }; + if (_closed || _enabled == enabled) + { + return std::nullopt; + } + + _enabled = enabled; + if (!enabled) + { + _explicitActive = false; + _shellSnapshot.reset(); + return _emit(HiddenSnapshot(ProgressStatus::Cancelled)); + } + return std::nullopt; + } + + std::optional ApplyTaskbar(const uint64_t state, const uint64_t value) noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock() || !_enabled || _closed) + { + return std::nullopt; + } + + const auto clamped = static_cast(std::min(value, 100)); + switch (state) + { + case 0: // Clear + _explicitActive = false; + return _emit(_shellSnapshot.value_or(HiddenSnapshot())); + case 1: // Set + _explicitActive = true; + _explicitSnapshot = { ProgressMode::Determinate, ProgressStatus::Running, clamped, true, ProgressSource::Taskbar, 0 }; + break; + case 2: // Error + _explicitActive = true; + _explicitSnapshot = { ProgressMode::Determinate, ProgressStatus::Error, _meaningfulValue(clamped), true, ProgressSource::Taskbar, 0 }; + break; + case 3: // Indeterminate + _explicitActive = true; + _explicitSnapshot = { ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::Taskbar, 0 }; + break; + case 4: // Paused + _explicitActive = true; + _explicitSnapshot = { ProgressMode::Determinate, ProgressStatus::Waiting, _meaningfulValue(clamped), true, ProgressSource::Taskbar, 0 }; + break; + default: + return std::nullopt; + } + return _emit(_explicitSnapshot); + } + + std::optional ApplyShellLifecycle(const ShellLifecycleState state, const int64_t exitCode) noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock() || !_enabled || _closed) + { + return std::nullopt; + } + + switch (state) + { + case ShellLifecycleState::Prompt: + _shellSnapshot.reset(); + break; + case ShellLifecycleState::CommandStart: + case ShellLifecycleState::CommandExecuted: + _shellSnapshot = ProgressSnapshot{ ProgressMode::Indeterminate, ProgressStatus::Running, 0, true, ProgressSource::ShellIntegration, 0 }; + break; + case ShellLifecycleState::CommandFinished: + _shellSnapshot = ProgressSnapshot{ + ProgressMode::Determinate, + exitCode > 0 ? ProgressStatus::Error : ProgressStatus::Success, + 100, + true, + ProgressSource::ShellIntegration, + 0 + }; + break; + case ShellLifecycleState::None: + default: + return std::nullopt; + } + + return _explicitActive ? std::nullopt : _emit(_shellSnapshot.value_or(HiddenSnapshot())); + } + + std::optional Reset() noexcept + { + std::scoped_lock lock{ _mutex }; + _explicitActive = false; + _shellSnapshot.reset(); + return _emit(HiddenSnapshot()); + } + + std::optional Close() noexcept + { + std::scoped_lock lock{ _mutex }; + if (_closed) + { + return std::nullopt; + } + _closed = true; + _enabled = false; + _explicitActive = false; + _shellSnapshot.reset(); + return _emit(HiddenSnapshot(ProgressStatus::Cancelled)); + } + + ProgressSnapshot Current() const noexcept + { + std::scoped_lock lock{ _mutex }; + return _current; + } + + private: + static ProgressSnapshot HiddenSnapshot(const ProgressStatus status = ProgressStatus::Cancelled) noexcept + { + return { ProgressMode::Hidden, status, 0, false, ProgressSource::None, 0 }; + } + + uint8_t _meaningfulValue(const uint8_t value) const noexcept + { + if (value == 0 && _explicitSnapshot.mode == ProgressMode::Determinate && _explicitSnapshot.value > 0) + { + return _explicitSnapshot.value; + } + return value; + } + + std::optional _emit(ProgressSnapshot snapshot) noexcept + { + if (_current.SamePresentation(snapshot)) + { + return std::nullopt; + } + snapshot.sequence = ++_sequence; + _current = snapshot; + return snapshot; + } + + mutable std::mutex _mutex; + bool _enabled{}; + bool _closed{}; + bool _explicitActive{}; + uint64_t _sequence{}; + ProgressSnapshot _current{}; + ProgressSnapshot _explicitSnapshot{}; + std::optional _shellSnapshot; + }; + + // A one-element mailbox bounds cross-thread UI work. Newer updates replace + // older pending updates; terminal correctness always has priority over visuals. + class ProgressUpdateMailbox final + { + public: + bool Publish(const ProgressSnapshot& snapshot) noexcept + { + std::unique_lock lock{ _mutex, std::try_to_lock }; + if (!lock.owns_lock() || _closed) + { + return false; + } + _pending = snapshot; + return true; + } + + std::optional TakeLatest() noexcept + { + std::scoped_lock lock{ _mutex }; + auto latest = _pending; + _pending.reset(); + return latest; + } + + bool HasPending() const noexcept + { + std::scoped_lock lock{ _mutex }; + return _pending.has_value(); + } + + void Close() noexcept + { + std::scoped_lock lock{ _mutex }; + _closed = true; + _pending.reset(); + } + + void Reopen() noexcept + { + std::scoped_lock lock{ _mutex }; + _closed = false; + _pending.reset(); + } + + private: + mutable std::mutex _mutex; + bool _closed{}; + std::optional _pending; + }; +} From cb8459fdbbac784e0f113e2d37489ad63f18b73c Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 02:47:58 +0800 Subject: [PATCH 3/4] feat: add static per-pane progress overlay --- src/cascadia/TerminalApp/App.xaml | 30 +++ src/cascadia/TerminalApp/Pane.cpp | 294 +++++++++++++++++++++- src/cascadia/TerminalApp/Pane.h | 31 +++ src/cascadia/TerminalApp/TerminalPage.cpp | 20 ++ src/winterm/Design/ColorTokens.h | 4 + 5 files changed, 378 insertions(+), 1 deletion(-) diff --git a/src/cascadia/TerminalApp/App.xaml b/src/cascadia/TerminalApp/App.xaml index 182ee0342..60e129ee7 100644 --- a/src/cascadia/TerminalApp/App.xaml +++ b/src/cascadia/TerminalApp/App.xaml @@ -238,6 +238,16 @@ Color="#8E9EAD" /> + + + + + @@ -288,6 +298,16 @@ Color="#5F6F7C" /> + + + + + @@ -339,6 +359,16 @@ ResourceKey="SystemColorGrayTextColorBrush" /> + + + + + diff --git a/src/cascadia/TerminalApp/Pane.cpp b/src/cascadia/TerminalApp/Pane.cpp index de7e663d4..f4c08b7e5 100644 --- a/src/cascadia/TerminalApp/Pane.cpp +++ b/src/cascadia/TerminalApp/Pane.cpp @@ -1175,6 +1175,8 @@ void Pane::_ContentLostFocusHandler(const winrt::Windows::Foundation::IInspectab // - void Pane::Close() { + _visualProgressState.Close(); + _visualProgressMailbox.Close(); _setPaneContent(nullptr); // Fire our Closed event to tell our parent that we should be removed. Closed.raise(nullptr, nullptr); @@ -1187,6 +1189,8 @@ void Pane::Shutdown() { if (_IsLeaf()) { + _visualProgressState.Close(); + _visualProgressMailbox.Close(); _setPaneContent(nullptr); } else @@ -1478,6 +1482,10 @@ void Pane::UpdateVisuals() } _UpdatePaneHeader(); } + if (_visualProgressOverlay) + { + _ApplyVisualProgressSnapshot(_visualProgressState.Current()); + } } // Method Description: @@ -1544,6 +1552,11 @@ void Pane::_FocusFirstChild() void Pane::UpdateSettings(const CascadiaSettings& settings) { const auto globals = settings.GlobalSettings(); + const auto emergencyOverride = wil::TryGetEnvironmentVariableW(L"WINTERM_DISABLE_VISUAL_PROGRESS"); + _SetVisualProgressEnabled(winTerm::VisualProgress::IsFeatureEnabled( + globals.VisualProgressEnabled(), + emergencyOverride)); + _paneResizeSettings.enableSnapping = globals.PaneResizeSnapping(); switch (globals.PaneResizeSnapPoints()) { @@ -2032,7 +2045,11 @@ IPaneContent Pane::_takePaneContent() _closeRequestedRevoker.revoke(); _paneTitleChangedRevoker.revoke(); _paneTaskbarProgressChangedRevoker.revoke(); + _paneShellIntegrationChangedRevoker.revoke(); _paneReadOnlyChangedRevoker.revoke(); + _visualProgressState.Reset(); + _visualProgressMailbox.Close(); + _DestroyVisualProgressOverlay(); if (_contentHost) { _contentHost.Child(nullptr); @@ -2055,13 +2072,23 @@ void Pane::_setPaneContent(IPaneContent content) if (content) { _content = std::move(content); + _visualProgressMailbox.Reopen(); _closeRequestedRevoker = _content.CloseRequested(winrt::auto_revoke, [this](auto&&, auto&&) { Close(); }); _paneTitleChangedRevoker = _content.TitleChanged( winrt::auto_revoke, [this](auto&&, auto&&) { _UpdatePaneHeader(); }); _paneTaskbarProgressChangedRevoker = _content.TaskbarProgressChanged( winrt::auto_revoke, - [this](auto&&, auto&&) { _UpdatePaneHeader(); }); + [this](auto&&, auto&&) { + _UpdatePaneHeader(); + _UpdateVisualProgressFromTaskbar(); + }); + if (const auto terminalContent = _content.try_as()) + { + _paneShellIntegrationChangedRevoker = terminalContent.ShellIntegrationChanged( + winrt::auto_revoke, + [this](auto&&, auto&&) { _UpdateVisualProgressFromShellIntegration(); }); + } _paneReadOnlyChangedRevoker = _content.ReadOnlyChanged( winrt::auto_revoke, [this](auto&&, auto&&) { _UpdatePaneHeader(); }); @@ -2168,6 +2195,7 @@ void Pane::_AttachLeafVisual() _leafLayout.Children().Append(_paneHeader); _leafLayout.Children().Append(_contentHost); _contentHost.Child(_content.GetRoot()); + _CreateVisualProgressOverlay(); if (const auto terminal = GetTerminalControl()) { @@ -2187,6 +2215,269 @@ void Pane::_AttachLeafVisual() _UpdatePaneHeader(); } +void Pane::_SetVisualProgressEnabled(const bool enabled) +{ + if (_visualProgressEnabled.load(std::memory_order_acquire) == enabled) + { + return; + } + + _visualProgressEnabled.store(enabled, std::memory_order_release); + if (!enabled) + { + _visualProgressState.SetEnabled(false); + _visualProgressMailbox.Close(); + _DestroyVisualProgressOverlay(); + return; + } + + _visualProgressFaulted.store(false, std::memory_order_release); + _visualProgressMailbox.Reopen(); + _visualProgressState.SetEnabled(true); + if (_IsLeaf() && _content) + { + _CreateVisualProgressOverlay(); + _UpdateVisualProgressFromShellIntegration(); + _UpdateVisualProgressFromTaskbar(); + } +} + +void Pane::_CreateVisualProgressOverlay() +{ + if (!_visualProgressEnabled.load(std::memory_order_acquire) || _visualProgressFaulted.load(std::memory_order_acquire) || !_leafLayout || _visualProgressOverlay) + { + return; + } + + try + { + _visualProgressOverlay = Controls::Grid{}; + _visualProgressFillLayout = Controls::Grid{}; + _visualProgressTrack = Controls::Border{}; + _visualProgressFill = Controls::Border{}; + _visualProgressLeadingColumn = Controls::ColumnDefinition{}; + _visualProgressFillColumn = Controls::ColumnDefinition{}; + _visualProgressTrailingColumn = Controls::ColumnDefinition{}; + + _visualProgressOverlay.Height(6.0); + _visualProgressOverlay.Margin(ThicknessHelper::FromLengths(10.0, 0.0, 10.0, 8.0)); + _visualProgressOverlay.VerticalAlignment(VerticalAlignment::Bottom); + _visualProgressOverlay.HorizontalAlignment(HorizontalAlignment::Stretch); + _visualProgressOverlay.IsHitTestVisible(false); + _visualProgressOverlay.Visibility(Visibility::Collapsed); + Controls::Grid::SetRow(_visualProgressOverlay, 1); + Automation::AutomationProperties::SetAccessibilityView( + _visualProgressOverlay, + Automation::Peers::AccessibilityView::Raw); + + const auto radius = CornerRadiusHelper::FromUniformRadius(winTerm::Design::RadiusTokens::CompactControl); + _visualProgressTrack.CornerRadius(radius); + _visualProgressTrack.IsHitTestVisible(false); + _visualProgressFill.CornerRadius(radius); + _visualProgressFill.IsHitTestVisible(false); + + _visualProgressFillLayout.ColumnDefinitions().Append(_visualProgressLeadingColumn); + _visualProgressFillLayout.ColumnDefinitions().Append(_visualProgressFillColumn); + _visualProgressFillLayout.ColumnDefinitions().Append(_visualProgressTrailingColumn); + Controls::Grid::SetColumn(_visualProgressFill, 1); + _visualProgressFillLayout.Children().Append(_visualProgressFill); + _visualProgressOverlay.Children().Append(_visualProgressTrack); + _visualProgressOverlay.Children().Append(_visualProgressFillLayout); + _leafLayout.Children().Append(_visualProgressOverlay); + _ApplyVisualProgressSnapshot(_visualProgressState.Current()); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _DisableVisualProgressOnUI(); + } +} + +void Pane::_DestroyVisualProgressOverlay() noexcept +{ + try + { + if (_leafLayout && _visualProgressOverlay) + { + uint32_t index{}; + if (_leafLayout.Children().IndexOf(_visualProgressOverlay, index)) + { + _leafLayout.Children().RemoveAt(index); + } + } + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } + + _visualProgressOverlay = nullptr; + _visualProgressFillLayout = nullptr; + _visualProgressTrack = nullptr; + _visualProgressFill = nullptr; + _visualProgressLeadingColumn = nullptr; + _visualProgressFillColumn = nullptr; + _visualProgressTrailingColumn = nullptr; +} + +void Pane::_UpdateVisualProgressFromTaskbar() +{ + if (!_visualProgressEnabled.load(std::memory_order_acquire) || _visualProgressFaulted.load(std::memory_order_acquire) || !_content) + { + return; + } + if (const auto snapshot = _visualProgressState.ApplyTaskbar(_content.TaskbarState(), _content.TaskbarProgress())) + { + _QueueVisualProgressUpdate(*snapshot); + } +} + +void Pane::_UpdateVisualProgressFromShellIntegration() +{ + if (!_visualProgressEnabled.load(std::memory_order_acquire) || _visualProgressFaulted.load(std::memory_order_acquire)) + { + return; + } + if (const auto terminalContent = _content.try_as()) + { + const auto state = static_cast(terminalContent.ShellIntegrationState()); + if (const auto snapshot = _visualProgressState.ApplyShellLifecycle(state, terminalContent.ShellIntegrationExitCode())) + { + _QueueVisualProgressUpdate(*snapshot); + } + } +} + +void Pane::_QueueVisualProgressUpdate(const winTerm::VisualProgress::ProgressSnapshot& snapshot) +{ + if (!_visualProgressEnabled.load(std::memory_order_acquire) || _visualProgressFaulted.load(std::memory_order_acquire) || !_visualProgressMailbox.Publish(snapshot)) + { + return; + } + _ScheduleVisualProgressUpdate(); +} + +void Pane::_ScheduleVisualProgressUpdate() +{ + if (_visualProgressUpdateQueued.exchange(true, std::memory_order_acq_rel)) + { + return; + } + + try + { + const auto dispatcher = _root.Dispatcher(); + if (!dispatcher) + { + _visualProgressUpdateQueued.store(false, std::memory_order_release); + _visualProgressFaulted.store(true, std::memory_order_release); + _visualProgressMailbox.Close(); + LOG_HR(E_FAIL); + return; + } + + std::weak_ptr weakThis{ shared_from_this() }; + dispatcher.RunAsync(CoreDispatcherPriority::Low, [weakThis]() { + if (const auto pane = weakThis.lock()) + { + const auto snapshot = pane->_visualProgressMailbox.TakeLatest(); + pane->_visualProgressUpdateQueued.store(false, std::memory_order_release); + if (snapshot && pane->_visualProgressEnabled.load(std::memory_order_acquire) && !pane->_visualProgressFaulted.load(std::memory_order_acquire)) + { + pane->_ApplyVisualProgressSnapshot(*snapshot); + } + if (pane->_visualProgressMailbox.HasPending()) + { + pane->_ScheduleVisualProgressUpdate(); + } + } + }); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _visualProgressUpdateQueued.store(false, std::memory_order_release); + _visualProgressFaulted.store(true, std::memory_order_release); + _visualProgressMailbox.Close(); + } +} + +void Pane::_ApplyVisualProgressSnapshot(const winTerm::VisualProgress::ProgressSnapshot& snapshot) noexcept +{ + try + { + if (!_visualProgressOverlay || !_visualProgressFill || !_visualProgressTrack || + !_visualProgressLeadingColumn || !_visualProgressFillColumn || !_visualProgressTrailingColumn) + { + return; + } + + _visualProgressTrack.Background( + _themeResources.progressTrackBrush ? + _themeResources.progressTrackBrush : + TokenBrush(winTerm::Design::ColorTokens::ProgressTrack)); + _visualProgressFill.Background(_VisualProgressBrush(snapshot.status)); + _visualProgressOverlay.Visibility(snapshot.visible ? Visibility::Visible : Visibility::Collapsed); + if (!snapshot.visible) + { + return; + } + + double leading{}; + double fill{ static_cast(snapshot.value) }; + double trailing{ 100.0 - fill }; + if (snapshot.mode == winTerm::VisualProgress::ProgressMode::Indeterminate) + { + leading = 30.0; + fill = 40.0; + trailing = 30.0; + } + _visualProgressLeadingColumn.Width(GridLengthHelper::FromValueAndType(leading, GridUnitType::Star)); + _visualProgressFillColumn.Width(GridLengthHelper::FromValueAndType(fill, GridUnitType::Star)); + _visualProgressTrailingColumn.Width(GridLengthHelper::FromValueAndType(trailing, GridUnitType::Star)); + _visualProgressFill.Visibility(fill > 0.0 ? Visibility::Visible : Visibility::Collapsed); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _DisableVisualProgressOnUI(); + } +} + +void Pane::_DisableVisualProgressOnUI() noexcept +{ + _visualProgressFaulted.store(true, std::memory_order_release); + _visualProgressEnabled.store(false, std::memory_order_release); + _visualProgressState.SetEnabled(false); + _visualProgressMailbox.Close(); + _DestroyVisualProgressOverlay(); +} + +SolidColorBrush Pane::_VisualProgressBrush(const winTerm::VisualProgress::ProgressStatus status) const +{ + switch (status) + { + case winTerm::VisualProgress::ProgressStatus::Waiting: + return _themeResources.progressWaitingBrush ? + _themeResources.progressWaitingBrush : + TokenBrush(winTerm::Design::ColorTokens::ProgressWaiting); + case winTerm::VisualProgress::ProgressStatus::Success: + return _themeResources.progressSuccessBrush ? + _themeResources.progressSuccessBrush : + TokenBrush(winTerm::Design::ColorTokens::ProgressSuccess); + case winTerm::VisualProgress::ProgressStatus::Error: + return _themeResources.progressErrorBrush ? + _themeResources.progressErrorBrush : + TokenBrush(winTerm::Design::ColorTokens::ProgressError); + case winTerm::VisualProgress::ProgressStatus::Running: + case winTerm::VisualProgress::ProgressStatus::Cancelled: + default: + return _themeResources.progressRunningBrush ? + _themeResources.progressRunningBrush : + TokenBrush(winTerm::Design::ColorTokens::AccentMint); + } +} + void Pane::_UpdatePaneHeader() { if (!_paneHeader || !_paneTitle || !_paneIcon || !_paneStatus) @@ -3314,6 +3605,7 @@ std::pair, std::shared_ptr> Pane::_Split(SplitDirect // Move our control, guid, isDefTermSession into the first one. _firstChild = std::make_shared(_takePaneContent()); _firstChild->_broadcastEnabled = _broadcastEnabled; + _firstChild->_SetVisualProgressEnabled(_visualProgressEnabled.load(std::memory_order_acquire)); } _splitState = actualSplitType; diff --git a/src/cascadia/TerminalApp/Pane.h b/src/cascadia/TerminalApp/Pane.h index 007267b35..5e3f130ee 100644 --- a/src/cascadia/TerminalApp/Pane.h +++ b/src/cascadia/TerminalApp/Pane.h @@ -23,6 +23,9 @@ #include "TaskbarState.h" #include "TerminalPaneContent.h" #include "../../winterm/PaneResize/PaneResizeModel.h" +#include "../../winterm/VisualProgress/VisualProgressModel.h" + +#include // fwdecl unittest classes namespace TerminalAppLocalTests @@ -66,6 +69,11 @@ struct PaneResources winrt::Windows::UI::Xaml::Media::SolidColorBrush accentBrush{ nullptr }; winrt::Windows::UI::Xaml::Media::SolidColorBrush dividerBrush{ nullptr }; winrt::Windows::UI::Xaml::Media::SolidColorBrush dividerHoverBrush{ nullptr }; + winrt::Windows::UI::Xaml::Media::SolidColorBrush progressTrackBrush{ nullptr }; + winrt::Windows::UI::Xaml::Media::SolidColorBrush progressRunningBrush{ nullptr }; + winrt::Windows::UI::Xaml::Media::SolidColorBrush progressWaitingBrush{ nullptr }; + winrt::Windows::UI::Xaml::Media::SolidColorBrush progressSuccessBrush{ nullptr }; + winrt::Windows::UI::Xaml::Media::SolidColorBrush progressErrorBrush{ nullptr }; }; class Pane : public std::enable_shared_from_this @@ -268,6 +276,13 @@ class Pane : public std::enable_shared_from_this winrt::Windows::UI::Xaml::Controls::Border _dividerVisual{ nullptr }; winrt::Windows::UI::Xaml::Controls::Border _snapIndicator{ nullptr }; winrt::Windows::UI::Xaml::Controls::TextBlock _snapIndicatorText{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Grid _visualProgressOverlay{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Grid _visualProgressFillLayout{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Border _visualProgressTrack{ nullptr }; + winrt::Windows::UI::Xaml::Controls::Border _visualProgressFill{ nullptr }; + winrt::Windows::UI::Xaml::Controls::ColumnDefinition _visualProgressLeadingColumn{ nullptr }; + winrt::Windows::UI::Xaml::Controls::ColumnDefinition _visualProgressFillColumn{ nullptr }; + winrt::Windows::UI::Xaml::Controls::ColumnDefinition _visualProgressTrailingColumn{ nullptr }; PaneResources _themeResources; @@ -291,6 +306,7 @@ class Pane : public std::enable_shared_from_this winrt::TerminalApp::IPaneContent::CloseRequested_revoker _closeRequestedRevoker; winrt::TerminalApp::IPaneContent::TitleChanged_revoker _paneTitleChangedRevoker; winrt::TerminalApp::IPaneContent::TaskbarProgressChanged_revoker _paneTaskbarProgressChangedRevoker; + winrt::TerminalApp::TerminalPaneContent::ShellIntegrationChanged_revoker _paneShellIntegrationChangedRevoker; winrt::TerminalApp::IPaneContent::ReadOnlyChanged_revoker _paneReadOnlyChangedRevoker; Borders _borders{ Borders::None }; @@ -306,6 +322,11 @@ class Pane : public std::enable_shared_from_this std::optional _resizePointerId; std::unique_ptr _resizeTransaction; bool _dividerPointerOver{ false }; + std::atomic _visualProgressEnabled{ false }; + std::atomic _visualProgressFaulted{ false }; + std::atomic _visualProgressUpdateQueued{ false }; + winTerm::VisualProgress::ProgressStateMachine _visualProgressState; + winTerm::VisualProgress::ProgressUpdateMailbox _visualProgressMailbox; bool _IsLeaf() const noexcept; bool _HasFocusedChild() const noexcept; @@ -313,6 +334,16 @@ class Pane : public std::enable_shared_from_this winrt::TerminalApp::IPaneContent _takePaneContent(); void _setPaneContent(winrt::TerminalApp::IPaneContent content); void _AttachLeafVisual(); + void _SetVisualProgressEnabled(bool enabled); + void _CreateVisualProgressOverlay(); + void _DestroyVisualProgressOverlay() noexcept; + void _UpdateVisualProgressFromTaskbar(); + void _UpdateVisualProgressFromShellIntegration(); + void _QueueVisualProgressUpdate(const winTerm::VisualProgress::ProgressSnapshot& snapshot); + void _ScheduleVisualProgressUpdate(); + void _ApplyVisualProgressSnapshot(const winTerm::VisualProgress::ProgressSnapshot& snapshot) noexcept; + void _DisableVisualProgressOnUI() noexcept; + winrt::Windows::UI::Xaml::Media::SolidColorBrush _VisualProgressBrush(winTerm::VisualProgress::ProgressStatus status) const; void _UpdatePaneHeader(); winrt::hstring _PaneHeaderTitle() const; winrt::hstring _PaneHeaderAccessibleTitle() const; diff --git a/src/cascadia/TerminalApp/TerminalPage.cpp b/src/cascadia/TerminalApp/TerminalPage.cpp index 9f29d54bd..89b0fbb6c 100644 --- a/src/cascadia/TerminalApp/TerminalPage.cpp +++ b/src/cascadia/TerminalApp/TerminalPage.cpp @@ -5260,6 +5260,26 @@ namespace winrt::TerminalApp::implementation L"WinTermDividerHoverBrush", _paneResources.dividerHoverBrush, winTerm::Design::ColorTokens::BorderHover); + loadWinTermBrush( + L"WinTermVisualProgressTrackBrush", + _paneResources.progressTrackBrush, + winTerm::Design::ColorTokens::ProgressTrack); + loadWinTermBrush( + L"WinTermVisualProgressRunningBrush", + _paneResources.progressRunningBrush, + winTerm::Design::ColorTokens::AccentMint); + loadWinTermBrush( + L"WinTermVisualProgressWaitingBrush", + _paneResources.progressWaitingBrush, + winTerm::Design::ColorTokens::ProgressWaiting); + loadWinTermBrush( + L"WinTermVisualProgressSuccessBrush", + _paneResources.progressSuccessBrush, + winTerm::Design::ColorTokens::ProgressSuccess); + loadWinTermBrush( + L"WinTermVisualProgressErrorBrush", + _paneResources.progressErrorBrush, + winTerm::Design::ColorTokens::ProgressError); } void TerminalPage::_adjustProcessPriority() const diff --git a/src/winterm/Design/ColorTokens.h b/src/winterm/Design/ColorTokens.h index a4f69d8b1..d29e478c3 100644 --- a/src/winterm/Design/ColorTokens.h +++ b/src/winterm/Design/ColorTokens.h @@ -27,4 +27,8 @@ namespace winTerm::Design::ColorTokens inline constexpr uint32_t TextSecondary{ 0xFFC5D0DA }; inline constexpr uint32_t TextMuted{ 0xFF8E9EAD }; inline constexpr uint32_t IndicatorSurface{ 0xF2111B24 }; + inline constexpr uint32_t ProgressTrack{ 0x99111820 }; + inline constexpr uint32_t ProgressWaiting{ 0xFFF4C95D }; + inline constexpr uint32_t ProgressSuccess{ 0xFF4DDC88 }; + inline constexpr uint32_t ProgressError{ 0xFFFF6B6B }; } From f686b6904fec94aeb9d6fe83770b3d8567362db6 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sat, 1 Aug 2026 02:48:32 +0800 Subject: [PATCH 4/4] test: cover visual progress lifecycle and usage --- docs/development/visual-progress-phase1.md | 85 ++++++++ .../winterm/invoke-visual-progress-smoke.ps1 | 59 ++++++ scripts/winterm/test-visual-progress.ps1 | 185 ++++++++++++++++++ scripts/winterm/test.ps1 | 6 + 4 files changed, 335 insertions(+) create mode 100644 docs/development/visual-progress-phase1.md create mode 100644 scripts/winterm/invoke-visual-progress-smoke.ps1 create mode 100644 scripts/winterm/test-visual-progress.ps1 diff --git a/docs/development/visual-progress-phase1.md b/docs/development/visual-progress-phase1.md new file mode 100644 index 000000000..d5f3854b8 --- /dev/null +++ b/docs/development/visual-progress-phase1.md @@ -0,0 +1,85 @@ +# Visual Progress Phase 1 + +Phase 1 provides the static, fail-open foundation for Visual Progress. It is a developer preview, not the finished v1.2.0 rainbow effect. + +## Architecture + +The implementation reuses the standard terminal progress path: + +`OSC 9;4` → terminal core taskbar state → `ControlCore` → `TermControl` → `TerminalPaneContent` → `Pane` + +`src/winterm/VisualProgress/VisualProgressModel.h` normalizes each pane independently into a small snapshot containing only mode, status, value, visibility, source, and sequence. It never stores terminal output, command text, paths, environment contents, or other user data. + +The existing OSC 133 semantic-mark parser remains authoritative for command lifecycle. A small event boundary now carries prompt, command-start, command-executed, command-finished, and exit-code state through the control layer. Visual Progress does not parse visible output or scan scrollback. + +Explicit taskbar progress takes precedence while active. The mappings are: + +| Existing state | Normalized mode | Normalized status | +| --- | --- | --- | +| Clear | Hidden, or current shell lifecycle fallback | Cancelled/hidden | +| Set | Determinate | Running | +| Indeterminate | Indeterminate | Running | +| Error | Determinate | Error | +| Paused | Determinate | Waiting | +| OSC 133 command start/executed | Indeterminate | Running | +| OSC 133 command finish, exit 0 | Determinate at 100 | Success | +| OSC 133 command finish, nonzero exit | Determinate at 100 | Error | +| Next OSC 133 prompt | Hidden | Cancelled/hidden | + +Determinate values are clamped to 0–100. Duplicate snapshots are suppressed. Event ingress uses try-lock-and-drop semantics, and a one-element mailbox coalesces rapid updates before low-priority UI dispatch, so terminal output never waits for the decorative UI, pending work is bounded, and the newest accepted visual state wins. + +## Preview feature gate + +The setting defaults to off. Developers can enable it as a global setting in `settings.json`: + +```json +{ + "visualProgress.enabled": true +} +``` + +Set `WINTERM_DISABLE_VISUAL_PROGRESS=1` before starting winTerm to disable the feature regardless of the setting. The emergency override always wins. When disabled, panes do not create the overlay, schedule UI updates, start timers, or create worker threads. + +## Static overlay and fail-open behavior + +Each enabled leaf pane owns one six-logical-pixel overlay in the existing content row. It is layered over the terminal, uses ten-pixel horizontal margins, never changes terminal rows or columns, is excluded from accessibility and hit testing, and is rebuilt or removed with the pane visual lifecycle. XAML logical sizing covers 100%, 125%, 150%, and 200% DPI, including narrow and zoomed panes. + +The track and status fills use dark, light, and High Contrast theme resources. Determinate width matches the normalized value; indeterminate progress uses a fixed centered segment and never presents a fake percentage. + +All overlay creation, dispatch, and update failures are caught and logged through the existing application logging macros. A failed pane overlay disables itself without affecting the terminal process, PTY, input, output, selection, copy/paste, or pane controls. No progress state is persisted to workspaces and no progress telemetry is emitted. + +## CI classification + +The PR workflow computes the exact base-to-head diff and chooses one class: + +- `docs-only`: conservative documentation allowlist; quick validation only. +- `code`: quick validation plus one x64 Release build and relevant compiled tests. +- `delivery`: workflow, version, installer, packaging, manifest, dependency, or build-system changes; quick validation, x64 Debug and Release builds, relevant compiled tests, unpackaged/Setup/Portable builds, lifecycle tests, and the exact artifact allowlist. + +The `ci:full` or `delivery` label forces delivery validation. Manual dispatch accepts `auto`, `fast`, or `full`. The final `ci-gate` job always appears and rejects any missing, failed, or cancelled required job. The separate tag-triggered `release.yml` remains authoritative and unchanged. + +Branch protection should require `ci-gate` after this workflow lands. + +## Testing + +Run source-only validation: + +```powershell +.\scripts\winterm\test-visual-progress.ps1 -SourceOnly +``` + +After building compiled tests, run: + +```powershell +.\scripts\winterm\test-visual-progress.ps1 -Configuration Release -Platform x64 -RequireCompiled +``` + +To exercise standard progress and semantic command states in an enabled winTerm pane without Docker, pip, or another CLI provider: + +```powershell +.\scripts\winterm\invoke-visual-progress-smoke.ps1 +``` + +## Phase 1 non-goals + +Phase 1 deliberately excludes animation, rainbow gradients, glow, bloom, sparks, interpolation, moving indeterminate effects, CLI-specific parsers, output suppression, labels, ETA, speed, notifications, and Settings UI polish. It adds no output-line parser, scrollback scan, background polling, persistent timer, or worker thread. diff --git a/scripts/winterm/invoke-visual-progress-smoke.ps1 b/scripts/winterm/invoke-visual-progress-smoke.ps1 new file mode 100644 index 000000000..317308381 --- /dev/null +++ b/scripts/winterm/invoke-visual-progress-smoke.ps1 @@ -0,0 +1,59 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param( + [Parameter()] + [ValidateRange(0, 10000)] + [int]$DelayMilliseconds = 800 +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Send-Osc +{ + param( + [Parameter(Mandatory)] + [string]$Payload + ) + + [Console]::Write("$([char]27)]$Payload$([char]7)") + if ($DelayMilliseconds -gt 0) + { + Start-Sleep -Milliseconds $DelayMilliseconds + } +} + +try +{ + Write-Host 'Visual Progress fixture: determinate 50%' + Send-Osc '9;4;1;50' + Write-Host 'Visual Progress fixture: paused at 65%' + Send-Osc '9;4;4;65' + Write-Host 'Visual Progress fixture: error retaining 65%' + Send-Osc '9;4;2;65' + Write-Host 'Visual Progress fixture: indeterminate' + Send-Osc '9;4;3' + Write-Host 'Visual Progress fixture: clear explicit progress' + Send-Osc '9;4;0' + + Write-Host 'Visual Progress fixture: semantic command start' + Send-Osc '133;B' + Send-Osc '133;C' + Write-Host 'Visual Progress fixture: semantic successful command finish' + Send-Osc '133;D;0' + Write-Host 'Visual Progress fixture: next prompt clears completion' + Send-Osc '133;A' + + Write-Host 'Visual Progress fixture: semantic failed command finish' + Send-Osc '133;B' + Send-Osc '133;D;1' + Send-Osc '133;A' + Send-Osc '9;4;0' +} +catch +{ + Write-Error "Visual Progress smoke fixture failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 new file mode 100644 index 000000000..226b7a7de --- /dev/null +++ b/scripts/winterm/test-visual-progress.ps1 @@ -0,0 +1,185 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Debug', + + [Parameter()] + [ValidateSet('x64')] + [string]$Platform = 'x64', + + [Parameter()] + [switch]$RequireCompiled, + + [Parameter()] + [switch]$SourceOnly +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-Contains +{ + param( + [Parameter(Mandatory)] + [string]$Content, + + [Parameter(Mandatory)] + [string]$Value, + + [Parameter(Mandatory)] + [string]$Description + ) + + if (-not $Content.Contains($Value)) + { + throw "$Description is missing '$Value'." + } +} + +try +{ + $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $requiredFiles = @( + 'src\winterm\VisualProgress\VisualProgressModel.h', + 'src\cascadia\UnitTests_SettingsModel\WinTermVisualProgressTests.cpp', + 'src\cascadia\TerminalApp\Pane.cpp', + 'src\cascadia\TerminalApp\Pane.h', + 'src\cascadia\TerminalApp\App.xaml', + 'src\cascadia\TerminalSettingsModel\MTSMSettings.h', + 'src\cascadia\TerminalSettingsModel\GlobalAppSettings.idl', + 'src\terminal\adapter\adaptDispatch.cpp', + 'docs\development\visual-progress-phase1.md', + 'scripts\winterm\invoke-visual-progress-smoke.ps1' + ) + foreach ($relativePath in $requiredFiles) + { + if (-not (Test-Path -LiteralPath (Join-Path $root $relativePath) -PathType Leaf)) + { + throw "Visual Progress boundary '$relativePath' is missing." + } + } + + $model = Get-Content -LiteralPath (Join-Path $root $requiredFiles[0]) -Raw + foreach ($required in @( + 'ProgressMode', + 'ProgressStatus', + 'ProgressSource', + 'ProgressStateMachine', + 'ProgressUpdateMailbox', + 'std::min(value, 100)', + 'SamePresentation', + 'ShellLifecycleState::CommandStart', + 'ShellLifecycleState::CommandFinished', + 'emergencyOverride != L"1"' + )) + { + Assert-Contains $model $required 'Normalized progress model' + } + if ($model -match '(?i)timer|animation|particle|spark|glow|bloom|scrollback|command text|environment variables') + { + throw 'The normalized Visual Progress core contains a Phase 2 effect, polling primitive, or sensitive data field.' + } + + $pane = Get-Content -LiteralPath (Join-Path $root $requiredFiles[2]) -Raw + foreach ($required in @( + 'WINTERM_DISABLE_VISUAL_PROGRESS', + '_content.TaskbarState()', + '_content.TaskbarProgress()', + 'ShellIntegrationChanged', + '_visualProgressOverlay.Height(6.0)', + 'ThicknessHelper::FromLengths(10.0, 0.0, 10.0, 8.0)', + '_visualProgressOverlay.IsHitTestVisible(false)', + 'Controls::Grid::SetRow(_visualProgressOverlay, 1)', + 'std::weak_ptr', + 'CoreDispatcherPriority::Low', + '_visualProgressMailbox.TakeLatest()', + '_paneTaskbarProgressChangedRevoker.revoke()', + '_paneShellIntegrationChangedRevoker.revoke()', + '_visualProgressState.Reset()', + '_visualProgressMailbox.Close()', + 'LOG_CAUGHT_EXCEPTION()', + '_DestroyVisualProgressOverlay()' + )) + { + Assert-Contains $pane $required 'Per-pane Visual Progress integration' + } + if ($pane -match '(?i)DispatcherTimer|CompositionAnimation|VisualProgress.*Storyboard') + { + throw 'The Phase 1 pane overlay must remain static and timer-free.' + } + + $settings = Get-Content -LiteralPath (Join-Path $root $requiredFiles[5]) -Raw + Assert-Contains $settings 'VisualProgressEnabled, "visualProgress.enabled", false' 'Visual Progress setting' + $settingsIdl = Get-Content -LiteralPath (Join-Path $root $requiredFiles[6]) -Raw + Assert-Contains $settingsIdl 'INHERITABLE_SETTING(Boolean, VisualProgressEnabled)' 'Visual Progress setting projection' + + $dispatch = Get-Content -LiteralPath (Join-Path $root $requiredFiles[7]) -Raw + foreach ($required in @( + 'ShellIntegrationMarkKind::Prompt', + 'ShellIntegrationMarkKind::CommandStart', + 'ShellIntegrationMarkKind::CommandExecuted', + 'ShellIntegrationMarkKind::CommandFinished' + )) + { + Assert-Contains $dispatch $required 'Semantic shell lifecycle boundary' + } + + $tests = Get-Content -LiteralPath (Join-Path $root $requiredFiles[1]) -Raw + foreach ($required in @( + 'MapEveryTaskbarState', + 'ClampDeterminateValues', + 'SuppressDuplicateState', + 'EmergencyOverridePrecedesSetting', + 'MultiplePanesRemainIndependent', + 'CloseAndDetachCleanupStopsUpdates', + 'SplitOrDetachResetClearsReusableState', + 'FeatureReloadDisablesAndReenablesCleanly', + 'MailboxCoalescesRapidUpdatesAndReleasesOnClose', + 'SettingSerializesAndMissingSettingDefaultsOff' + )) + { + Assert-Contains $tests $required 'Visual Progress compiled test coverage' + } + + $project = Get-Content -LiteralPath (Join-Path $root 'src\cascadia\UnitTests_SettingsModel\SettingsModel.UnitTests.vcxproj') -Raw + Assert-Contains $project '' 'Compiled Visual Progress test registration' + + $fixture = Get-Content -LiteralPath (Join-Path $root $requiredFiles[9]) -Raw + foreach ($required in @('9;4;0', '9;4;1;50', '9;4;3', '9;4;4;65', '9;4;2;65', '133;B', '133;D;0')) + { + Assert-Contains $fixture $required 'Manual Visual Progress fixture' + } + + $testBinary = Join-Path $root "bin\$Platform\$Configuration\UnitTests_SettingsModel\SettingsModel.Unit.Tests.dll" + if ($SourceOnly) + { + Write-Host 'SKIP: compiled Visual Progress tests are handled by the parent suite.' -ForegroundColor Yellow + } + elseif (Test-Path -LiteralPath $testBinary -PathType Leaf) + { + & (Join-Path $PSScriptRoot 'test.ps1') -Suite Relevant -Configuration $Configuration -Platform $Platform + if (-not $?) + { + throw 'Compiled Visual Progress tests failed.' + } + } + elseif ($RequireCompiled) + { + throw "Compiled Settings Model tests were not found at '$testBinary'." + } + else + { + Write-Host 'SKIP: compiled Visual Progress tests are unavailable.' -ForegroundColor Yellow + } + + Write-Host 'PASS: Visual Progress Phase 1 source, lifecycle, and safety boundaries.' -ForegroundColor Green +} +catch +{ + Write-Error "Visual Progress tests failed: $($_.Exception.Message)" + exit 1 +} diff --git a/scripts/winterm/test.ps1 b/scripts/winterm/test.ps1 index 92492629e..36e81ba91 100644 --- a/scripts/winterm/test.ps1 +++ b/scripts/winterm/test.ps1 @@ -260,6 +260,12 @@ try throw 'Pane resize source validation failed.' } + & (Join-Path $PSScriptRoot 'test-visual-progress.ps1') -Configuration $Configuration -Platform $Platform -SourceOnly + if (-not $?) + { + throw 'Visual Progress source validation failed.' + } + & (Join-Path $PSScriptRoot 'test-diagnostics.ps1') if (-not $?) {