diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8ed016e0..5d0693418 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ concurrency: jobs: checkpoint-validation: name: Validate engineering checkpoint - if: ${{ contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]'), github.ref_name) }} + if: ${{ contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]'), github.ref_name) }} runs-on: windows-2022 timeout-minutes: 30 steps: @@ -35,7 +35,7 @@ jobs: validate: name: Validate exact release source - if: ${{ !contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]'), github.ref_name) }} + if: ${{ !contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]'), github.ref_name) }} runs-on: windows-2022 timeout-minutes: 30 steps: @@ -82,7 +82,7 @@ jobs: test-x64: name: Build and test x64 Release - if: ${{ !contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]'), github.ref_name) }} + if: ${{ !contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]'), github.ref_name) }} needs: validate runs-on: windows-2022 timeout-minutes: 240 @@ -102,7 +102,7 @@ jobs: release: name: Build, test, attest, and publish EXE and Portable ZIP - if: ${{ !contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]'), github.ref_name) }} + if: ${{ !contains(fromJSON('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]'), github.ref_name) }} needs: - validate - test-x64 diff --git a/CHANGELOG.md b/CHANGELOG.md index df2bdb7d4..11e542952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,75 @@ # Changelog +## 1.3.3 - 2026-08-12 + +Engineering checkpoint for the Pane Search roadmap, Phase 3: performance and +edge-case hardening. Like the earlier checkpoints, this version marks merged +development work only: it is not a published release and produces no +downloadable artifacts; GitHub Latest and WinGet keep pointing at the stable +v1.2.0, and v1.3.0-beta3 remains the newest published prerelease. + +### Changed + +- Live-typing searches are now coalesced per pane. Each executed search scans + the entire scrollback on the UI thread, and previously every keystroke ran + one such scan synchronously. Keystrokes arriving within 50 ms now collapse + into a single trailing search; the leading keystroke still searches + immediately, so a single keypress is as responsive as before. The coalesced + callback reads the search box's state at fire time, so the latest query + always wins and a stale queued query can never overwrite newer results. + Navigation (Enter, Shift+Enter, the arrow buttons) keeps executing + synchronously with the box's current text and therefore can never act on a + stale query. Deleting the query clears highlights, overview markers, and + status immediately, and a pending coalesced search after Esc finds the box + closed and does nothing. +- An open search now converges while output streams continuously. The + existing OutputIdle refresh is debounced (it fires only after 100 ms of + quiet), so sustained output such as `tail -f` starved it and froze the + match counter, highlights, and overview markers indefinitely. While a + search is active, a companion non-debounced throttle now refreshes the + search at most once per 500 ms during sustained output; quiet terminals + keep using the existing OutputIdle path unchanged. The throttle is armed + only from the output handler while a search is active — with search closed + the output path pays one relaxed atomic load and schedules nothing. +- The scrollbar mark bitmap now repaints only when one of its inputs changes + (geometry, mark categories, search state, pip color, or — while generic + marks render — the buffer). Its content is independent of the thumb + position, yet every throttled scroll tick used to re-enumerate the full + occurrence list and all mark rows to repaint an identical bitmap; plain + scrolling with a large result set now skips that work entirely. + +### Fixed + +- Resizing a pane with an active search no longer creates a stray selection. + The resize invalidation reused the search-close path, which converts the + focused match into a selection (GH#19358); after a reflow those stored + spans describe the pre-reflow buffer, so the resize could select arbitrary + coordinates and skew the recomputed current match. The close path keeps + selecting the focused result; the resize path now only invalidates. +- Switching between the main and alternate screen buffers now drops stored + search highlight spans immediately. They were computed against the other + buffer, and the renderer could paint them at wrong positions for up to one + refresh interval after entering or leaving a full-screen application. + Search itself keeps following the active buffer, recomputing against the + alternate screen while it is engaged and against the main buffer after it + ends. +- Clearing the search now releases the terminal-side highlight span copy + instead of retaining its capacity until the next search; with very high + match counts that memory previously stayed allocated after Esc. + +### Added + +- Deterministic regression coverage for the hardened edges: buffer mutation + invalidation and post-output count refresh, focused-match anchoring while + matches are appended, scrollback-eviction safety with a tiny history, + reflow invalidation with the no-stray-selection guarantee, alternate-screen + transitions, search-state generation and mid-output arming semantics, + scrollbar repaint-signature contracts, and wide-character span widths + (Traditional and Simplified Chinese, Japanese, Korean, accented Latin, and + emoji). A search-scan smoke benchmark logs per-query scan cost for common, + rare, no-match, regex, and invalid-regex queries without asserting on wall + clock, and scales up locally through `WINTERM_SEARCH_BENCH_LINES`. + ## 1.3.2 - 2026-08-12 Engineering checkpoint for the Pane Search roadmap, Phase 2: search UX and diff --git a/README.ja.md b/README.ja.md index 90f7769af..73230c072 100644 --- a/README.ja.md +++ b/README.ja.md @@ -38,7 +38,7 @@ Microsoft、Windows、Windows Terminalのロゴも使用していません。 - `winTerm--setup-x64.exe` — 現在のユーザー、または全ユーザーへのインストール用; - `winTerm--portable-x64.zip` — 展開してそのまま実行する用。 -現在のソースバージョンは `1.3.2` で、 +現在のソースバージョンは `1.3.3` で、 最新の安定版リリースは `1.2.0` です。公開されている資産の一覧とチェックサムの全体は、 [最新の公式リリース](https://github.com/HelloThisWorld/winTerm/releases/latest) を参照してください。 @@ -111,8 +111,8 @@ PowerShell 7と、[ビルド手順(英語)](docs/build.md)に記載された .\scripts\winterm\build.ps1 -Configuration Release -Platform x64 -IncludeTests .\scripts\winterm\test.ps1 -Suite Relevant -Configuration Release -Platform x64 .\scripts\winterm\build-unpackaged.ps1 -Configuration Release -Platform x64 -.\scripts\winterm\build-installer.ps1 -Version 1.3.2 -Platform x64 -.\scripts\winterm\build-portable.ps1 -Version 1.3.2 -Platform x64 +.\scripts\winterm\build-installer.ps1 -Version 1.3.3 -Platform x64 +.\scripts\winterm\build-portable.ps1 -Version 1.3.3 -Platform x64 ``` アンパッケージ形式の生成処理では、統合されたリソースインデックスを作るための diff --git a/README.md b/README.md index 957671a0f..58dab2537 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ application downloads are: - `winTerm--setup-x64.exe` — current-user or all-users installation; - `winTerm--portable-x64.zip` — extract and run without installation. -The current source version is `1.3.2`; +The current source version is `1.3.3`; the latest stable release is `1.2.0`. See the [latest official Release](https://github.com/HelloThisWorld/winTerm/releases/latest) for the complete published asset list and checksums. @@ -106,8 +106,8 @@ Use PowerShell 7 and the Microsoft Terminal toolchain described in .\scripts\winterm\build.ps1 -Configuration Release -Platform x64 -IncludeTests .\scripts\winterm\test.ps1 -Suite Relevant -Configuration Release -Platform x64 .\scripts\winterm\build-unpackaged.ps1 -Configuration Release -Platform x64 -.\scripts\winterm\build-installer.ps1 -Version 1.3.2 -Platform x64 -.\scripts\winterm\build-portable.ps1 -Version 1.3.2 -Platform x64 +.\scripts\winterm\build-installer.ps1 -Version 1.3.3 -Platform x64 +.\scripts\winterm\build-portable.ps1 -Version 1.3.3 -Platform x64 ``` The unpackaged generator uses an unsigned MSIX only as an upstream build diff --git a/docs/current-progress.md b/docs/current-progress.md index 9175b3c63..6cab7af53 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -4,22 +4,22 @@ Last updated: 2026-08-12 ## Repository state -- Branch: `feature/v1.3.2-pane-search-phase2`, based on `main` at - `8015963ad` (Pane Search Phase 1 checkpoint, pull request #44) +- Branch: `feature/v1.3.3-pane-search-phase3`, based on `main` at + `97ecb7e85` (Pane Search Phase 2 checkpoint, pull request #45) - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` -- Application version: `1.3.2` -- Package/file version: `1.3.2.0` -- PowerShell module version: `1.3.2` with no prerelease suffix +- Application version: `1.3.3` +- Package/file version: `1.3.3.0` +- PowerShell module version: `1.3.3` with no prerelease suffix - Release channel: `stable` (engineering checkpoint; nothing is published) -- Checkpoint tag: `v1.3.2`, listed with the earlier checkpoint tags in the +- Checkpoint tag: `v1.3.3`, listed with the earlier checkpoint tags in the release workflow so a pushed checkpoint tag runs quick validation only and can never produce release artifacts - Current public Latest: `v1.2.0`, the stable Visual Progress release - Newest published prerelease: `v1.3.0-beta3` on the beta channel - Supported target: Windows 11 x64 -`v1.3.2` is an engineering checkpoint, the second of the Pane Search roadmap +`v1.3.3` is an engineering checkpoint, the third of the Pane Search roadmap toward v1.4. Like the v1.2.1 through v1.2.4 Command Timeline checkpoints, it is a source/development version only: no GitHub Release, website slot, or WinGet update is produced, and `/releases/latest` keeps resolving to v1.2.0. @@ -28,9 +28,10 @@ WinGet update is produced, and `/releases/latest` keeps resolving to v1.2.0. - Phase 1 — active-pane search — complete at `1.3.1`. - Phase 2 — search UX and scrollbar overview — complete at `1.3.2`. -- Phase 3 — performance investigation and hardening — next, at `1.3.3`. +- Phase 3 — performance and edge-case hardening — complete at `1.3.3`. - Final integration — `1.4.0-alpha`, promoted to beta only after manual - user validation. + user validation. Starting `1.4.0-alpha` requires a separate instruction; + it is not begun automatically. Phase 1 made `Ctrl+F` (with the retained `Ctrl+Shift+F` alias) open the existing Microsoft Terminal search box inside the focused pane only, @@ -55,8 +56,25 @@ only through the existing throttled scrollbar update path — no timers, no polling. Split panes keep fully isolated search state, including their overview markers. -Deferred to Phase 3: performance work such as debouncing, large-scrollback -optimization, and search-related edge-case hardening. +Phase 3 hardened that experience for real terminal workloads without adding +any new search engine, index, or persistent state. Live typing is coalesced +per pane (50 ms, leading immediate + trailing latest, reading the search +box's state at fire time so the latest query always wins and navigation can +never act on a stale query). An open search now converges during sustained +output: the debounced OutputIdle refresh is complemented by a non-debounced +500 ms cap armed only while a search is active, so `tail -f`-style streams +no longer freeze the counter, highlights, or overview — and search closed +still means zero recurring search work. The scrollbar mark bitmap repaints +only when its inputs change, so plain scrolling with large result sets no +longer re-enumerates occurrences. Edge cases were fixed deterministically: +pane resize no longer converts pre-reflow spans into a stray selection, +main/alt buffer switches drop the other buffer's highlight spans immediately +(search keeps following the active buffer), and closing search releases the +terminal-side span copy. Regression tests cover mutation invalidation, +match-anchor stability during appended output, scrollback eviction, reflow, +alternate-screen transitions, generation/arming semantics, repaint-signature +contracts, wide-character spans (CJK, Korean, accented Latin, emoji), and a +log-only scan benchmark (`WINTERM_SEARCH_BENCH_LINES` scales it locally). ## Command Timeline status @@ -171,10 +189,10 @@ through pull request #32: ## Next steps -1. Pane Search Phase 3 (`1.3.3`): performance investigation and hardening. -2. Final integration checkpoint `1.4.0-alpha`, then manual user validation - before any beta promotion. -3. Collect Command Timeline beta feedback; promote a stable `v1.3.0` only +1. Final integration checkpoint `1.4.0-alpha`, then manual user validation + before any beta promotion. This stage requires a separate instruction + from the user; do not begin it automatically. +2. Collect Command Timeline beta feedback; promote a stable `v1.3.0` only after beta testing, which is the point at which Latest, WinGet, and the website stable slot move. diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 index e4b336106..b144a6610 100644 --- a/scripts/winterm/package-shell-assets.ps1 +++ b/scripts/winterm/package-shell-assets.ps1 @@ -32,9 +32,9 @@ foreach ($relativePath in $sourceAssets) } $version = Get-Content -LiteralPath (Join-Path $repositoryRoot 'shell\shared\version.json') -Raw | ConvertFrom-Json -if ($version.moduleVersion -ne '1.3.2' -or +if ($version.moduleVersion -ne '1.3.3' -or $version.modulePrerelease -ne '' -or - $version.applicationVersion -ne '1.3.2' -or + $version.applicationVersion -ne '1.3.3' -or $version.protocolVersion -ne 1) { throw 'The winTerm Shell asset version metadata is invalid.' diff --git a/scripts/winterm/test-release-workflow.ps1 b/scripts/winterm/test-release-workflow.ps1 index 192e837b7..0fa468e42 100644 --- a/scripts/winterm/test-release-workflow.ps1 +++ b/scripts/winterm/test-release-workflow.ps1 @@ -23,7 +23,7 @@ try "- 'v*'", 'checkpoint-validation:', 'Validate engineering checkpoint', - '["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]', + '["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]', 'verify-version.ps1 -RequireTag', 'test.ps1 -Suite Smoke', 'contents: write', @@ -63,7 +63,7 @@ try } } - $checkpointGuard = 'contains(fromJSON(''["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]''), github.ref_name)' + $checkpointGuard = 'contains(fromJSON(''["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]''), github.ref_name)' if ([regex]::Matches($workflow, [regex]::Escape($checkpointGuard)).Count -ne 4) { throw 'Release workflow must guard the checkpoint validation and all three full release jobs.' diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index 830d402fa..a714d20b7 100644 --- a/scripts/winterm/test-visual-progress.ps1 +++ b/scripts/winterm/test-visual-progress.ps1 @@ -1431,12 +1431,12 @@ try $version = $source.VersionMetadata | ConvertFrom-Json $expectedVersionValues = [ordered]@{ - applicationVersion = '1.3.2' - packageVersion = '1.3.2.0' - moduleVersion = '1.3.2' + applicationVersion = '1.3.3' + packageVersion = '1.3.3.0' + moduleVersion = '1.3.3' modulePrerelease = '' channel = 'stable' - tag = 'v1.3.2' + tag = 'v1.3.3' workspaceSchemaVersion = 2 dockingModelVersion = 1 shellProtocolVersion = 1 @@ -1451,39 +1451,39 @@ try } } $shellVersion = $source.ShellVersion | ConvertFrom-Json - if ($shellVersion.applicationVersion -ne '1.3.2' -or $shellVersion.moduleVersion -ne '1.3.2' -or $shellVersion.protocolVersion -ne 1) + if ($shellVersion.applicationVersion -ne '1.3.3' -or $shellVersion.moduleVersion -ne '1.3.3' -or $shellVersion.protocolVersion -ne 1) { - throw 'Shell version metadata does not match winTerm version 1.3.2 with protocol version 1.' + throw 'Shell version metadata does not match winTerm version 1.3.3 with protocol version 1.' } foreach ($surface in @( - @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.3.2" }'; Description = 'About release metadata' }, - @{ Content = $source.PackageManifest; Value = 'Version="1.3.2.0"'; Description = 'MSIX package manifest' }, - @{ Content = $source.HostResource; Value = 'FILEVERSION 1,3,2,0'; Description = 'Terminal host file version' }, - @{ Content = $source.HostResource; Value = '"ProductVersion", "1.3.2\0"'; Description = 'Terminal host display version' }, - @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,3,2,0'; Description = 'Shim file version' }, - @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.3.2\0"'; Description = 'Shim display version' }, + @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.3.3" }'; Description = 'About release metadata' }, + @{ Content = $source.PackageManifest; Value = 'Version="1.3.3.0"'; Description = 'MSIX package manifest' }, + @{ Content = $source.HostResource; Value = 'FILEVERSION 1,3,3,0'; Description = 'Terminal host file version' }, + @{ Content = $source.HostResource; Value = '"ProductVersion", "1.3.3\0"'; Description = 'Terminal host display version' }, + @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,3,3,0'; Description = 'Shim file version' }, + @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.3.3\0"'; Description = 'Shim display version' }, @{ Content = $source.CustomProps; Value = '1'; Description = 'Executable major version' }, @{ Content = $source.CustomProps; Value = '3'; Description = 'Executable minor version' }, - @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.3.2'"; Description = 'PowerShell module manifest' }, - @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.3.2'"; Description = 'PowerShell module runtime' }, + @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.3.3'"; Description = 'PowerShell module manifest' }, + @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.3.3'"; Description = 'PowerShell module runtime' }, @{ Content = $source.PackageShellAssets; Value = "'shell\shared\version.json'"; Description = 'Canonical shell version metadata packaging' }, - @{ Content = $source.WorkspaceSerializer; Value = '"1.3.2"'; Description = 'Workspace application-version fallback' } + @{ Content = $source.WorkspaceSerializer; Value = '"1.3.3"'; Description = 'Workspace application-version fallback' } )) { Assert-Contains $surface.Content $surface.Value $surface.Description } foreach ($required in @( - "applicationVersion -eq '1.3.2'", - "packageVersion -eq '1.3.2.0'", - "moduleVersion -eq '1.3.2'", - "tag -eq 'v1.3.2'", + "applicationVersion -eq '1.3.3'", + "packageVersion -eq '1.3.3.0'", + "moduleVersion -eq '1.3.3'", + "tag -eq 'v1.3.3'", "Workspace Schema version remains 2", "Docking Model version remains 1", "Shell Protocol version remains 1", "Theme Schema remains at version 1" )) { - Assert-Contains $source.VerifyVersion $required 'Authoritative v1.3.2 version validation surface' + Assert-Contains $source.VerifyVersion $required 'Authoritative v1.3.3 version validation surface' } $testBinary = Join-Path $root "bin\$Platform\$Configuration\UnitTests_SettingsModel\SettingsModel.Unit.Tests.dll" diff --git a/scripts/winterm/test.ps1 b/scripts/winterm/test.ps1 index 8e9e96f3c..5e46e0637 100644 --- a/scripts/winterm/test.ps1 +++ b/scripts/winterm/test.ps1 @@ -292,7 +292,7 @@ function Test-ShellExperienceFoundations } $manifest = Import-PowerShellDataFile -LiteralPath $moduleManifest - if ($manifest.ModuleVersion -ne '1.3.2' -or + if ($manifest.ModuleVersion -ne '1.3.3' -or $manifest.PrivateData.PSData.Prerelease -ne '' -or $manifest.PowerShellVersion -ne '5.1') { diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 6c1cb17d4..be11c4409 100644 --- a/scripts/winterm/verify-branding.ps1 +++ b/scripts/winterm/verify-branding.ps1 @@ -126,7 +126,7 @@ function Test-Manifest Test-Requirement -Condition ($null -ne $identity -and $identity.Name -eq 'HelloThisWorld.winTerm') -Message "$Path uses package identity HelloThisWorld.winTerm" Test-Requirement -Condition ($null -ne $identity -and $identity.Name -notmatch '^Microsoft\.') -Message "$Path does not use a Microsoft package name" Test-Requirement -Condition ($null -ne $identity -and $identity.Publisher -ceq $ExpectedPublisher) -Message "$Path uses the expected non-Microsoft publisher" - Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.3.2.0') -Message "$Path uses package version 1.3.2.0" + Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.3.3.0') -Message "$Path uses package version 1.3.3.0" Test-Requirement -Condition ($null -ne $properties -and $properties.DisplayName -eq 'winTerm') -Message "$Path package display name is winTerm" Test-Requirement -Condition ($null -ne $application -and $application.Id -eq 'winTerm') -Message "$Path application ID is winTerm" Test-Requirement -Condition ($null -ne $visualElements -and $visualElements.DisplayName -eq 'winTerm') -Message "$Path application display name is winTerm" diff --git a/scripts/winterm/verify-version.ps1 b/scripts/winterm/verify-version.ps1 index ec1fa3fb0..51cba6e64 100644 --- a/scripts/winterm/verify-version.ps1 +++ b/scripts/winterm/verify-version.ps1 @@ -49,9 +49,9 @@ try $versionPath = Join-Path $repositoryRoot 'src\winterm\Branding\version.json' $version = Get-Content -LiteralPath $versionPath -Raw | ConvertFrom-Json - Assert-Condition ($version.applicationVersion -eq '1.3.2') 'Application version is 1.3.2' - Assert-Condition ($version.packageVersion -eq '1.3.2.0') 'Package version is 1.3.2.0' - Assert-Condition ($version.moduleVersion -eq '1.3.2') 'PowerShell module version is 1.3.2' + Assert-Condition ($version.applicationVersion -eq '1.3.3') 'Application version is 1.3.3' + Assert-Condition ($version.packageVersion -eq '1.3.3.0') 'Package version is 1.3.3.0' + Assert-Condition ($version.moduleVersion -eq '1.3.3') 'PowerShell module version is 1.3.3' Assert-Condition ($version.modulePrerelease -eq '') 'PowerShell module has no prerelease suffix' # The release workflow treats any channel other than 'stable' as a @@ -66,7 +66,7 @@ try Assert-Condition ($version.packageVersion -match '^\d+\.\d+\.\d+\.\d+$') 'Package version stays a four-part numeric version' Assert-Condition ($version.moduleVersion -match '^\d+\.\d+\.\d+$') 'PowerShell module version stays numeric' - Assert-Condition ($version.tag -eq 'v1.3.2') 'Engineering checkpoint tag is v1.3.2' + Assert-Condition ($version.tag -eq 'v1.3.3') 'Engineering checkpoint tag is v1.3.3' Assert-Condition ($version.workspaceSchemaVersion -eq 2) 'Workspace Schema version remains 2' Assert-Condition ($version.dockingModelVersion -eq 1) 'Docking Model version remains 1' Assert-Condition ($version.shellProtocolVersion -eq 1) 'Shell Protocol version remains 1' @@ -90,7 +90,7 @@ try $moduleManifest = Import-PowerShellDataFile -LiteralPath (Join-Path $repositoryRoot 'shell\powershell\winTerm.Shell\winTerm.Shell.psd1') Assert-Condition ($moduleManifest.ModuleVersion.ToString() -eq $version.moduleVersion) 'PowerShell manifest version matches release metadata' Assert-Condition ($moduleManifest.PrivateData.PSData.Prerelease -eq $version.modulePrerelease) 'PowerShell manifest prerelease matches release metadata' - Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.3.2'")) 'PowerShell module runtime version matches release metadata' + Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.3.3'")) 'PowerShell module runtime version matches release metadata' $shellVersion = Get-Text 'shell\shared\version.json' | ConvertFrom-Json Assert-Condition ($shellVersion.applicationVersion -eq $version.applicationVersion) 'Shell asset application version matches release metadata' @@ -98,7 +98,7 @@ try Assert-Condition ($shellVersion.protocolVersion -eq $version.shellProtocolVersion) 'Shell asset protocol version matches release metadata' $releaseHeader = Get-Text 'src\winterm\Branding\ReleaseMetadata.h' - Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.3.2" }')) 'About metadata application version is 1.3.2' + Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.3.3" }')) 'About metadata application version is 1.3.3' Assert-Condition ($releaseHeader.Contains('ReleaseChannel{ L"Stable" }')) 'About metadata channel is Stable' Assert-Condition ($releaseHeader.Contains($version.microsoftTerminalUpstreamRevision)) 'About metadata contains the Microsoft Terminal upstream revision' Assert-Condition ($releaseHeader.Contains('WorkspaceSchemaVersion{ 2 }')) 'About metadata contains Workspace Schema version 2' @@ -129,14 +129,14 @@ try Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('WorkspaceSchemaVersion{ 2 }')) 'Workspace model remains at Schema version 2' Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('DockingModelVersion{ 1 }')) 'Workspace model remains at Docking version 1' - Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('applicationVersion{ "1.3.2" }')) 'Workspace model application-version fallback is 1.3.2' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('applicationVersion{ "1.3.3" }')) 'Workspace model application-version fallback is 1.3.3' Assert-Condition ((Get-Text 'src\winterm\Shell\Protocol\ShellIntegrationProtocol.h').Contains('ShellProtocolVersion{ 1 }')) 'Shell protocol remains at version 1' Assert-Condition ((Get-Text 'src\winterm\Appearance\Themes\ThemeDescriptor.h').Contains('CurrentThemeSchemaVersion{ 1 }')) 'Theme Schema remains at version 1' - Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.3.2"')) 'Workspace serializer application-version fallback is 1.3.2' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.3.3"')) 'Workspace serializer application-version fallback is 1.3.3' $releaseWorkflow = Get-Text '.github\workflows\release.yml' Assert-Condition ($releaseWorkflow.Contains("- 'v*'")) 'Release workflow accepts version tags through a generic guarded trigger' - Assert-Condition ($releaseWorkflow.Contains('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2"]')) 'Release workflow identifies engineering checkpoint tags' + Assert-Condition ($releaseWorkflow.Contains('["v1.2.1","v1.2.2","v1.2.3","v1.2.4","v1.3.1","v1.3.2","v1.3.3"]')) 'Release workflow identifies engineering checkpoint tags' Assert-Condition ($releaseWorkflow.Contains('checkpoint-validation:')) 'Release workflow retains quick validation for checkpoint tags' Assert-Condition ($releaseWorkflow.Contains("`$expectedTag = `"v`$(`$metadata.applicationVersion)`"")) 'Release workflow derives the expected tag from version.json' Assert-Condition ($releaseWorkflow.Contains("`$metadata.tag -cne `$expectedTag")) 'Release workflow rejects a version metadata tag mismatch' @@ -187,7 +187,7 @@ try if ($RequireTag) { $tag = (& git describe --tags --exact-match 2>$null).Trim() - Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.3.2' + Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.3.3' } Write-Host 'winTerm version consistency verification passed.' -ForegroundColor Green diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 index 058eb6016..4304a865e 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'winTerm.Shell.psm1' - ModuleVersion = '1.3.2' + ModuleVersion = '1.3.3' GUID = 'f65cd8f4-5d25-4a2a-a0d4-58df1ab3dc5a' Author = 'winTerm contributors' CompanyName = 'winTerm' diff --git a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 index 4c3203be6..606ab3824 100644 --- a/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 +++ b/shell/powershell/winTerm.Shell/winTerm.Shell.psm1 @@ -3,7 +3,7 @@ Set-StrictMode -Version Latest -$script:WinTermModuleVersion = '1.3.2' +$script:WinTermModuleVersion = '1.3.3' $script:WinTermProtocolVersion = 1 $script:WinTermIntegrationEnabled = $false $script:WinTermPromptWrapped = $false diff --git a/shell/shared/version.json b/shell/shared/version.json index ce69cc10e..8cc197724 100644 --- a/shell/shared/version.json +++ b/shell/shared/version.json @@ -1,6 +1,6 @@ { - "applicationVersion": "1.3.2", - "moduleVersion": "1.3.2", + "applicationVersion": "1.3.3", + "moduleVersion": "1.3.3", "modulePrerelease": "", "protocolVersion": 1 } diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index dbb70f0e6..ea5908120 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="1.3.3.0" /> winTerm diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 43588d720..23fb8e31a 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -32,6 +32,13 @@ using namespace winrt::Windows::ApplicationModel::DataTransfer; namespace winrt::Microsoft::Terminal::Control::implementation { + // Upper bound on how stale an open search may become while output + // streams continuously. OutputIdle (100ms, debounced) never fires under + // sustained output; this companion interval is not debounced, so an + // active search re-converges at most this often. Each refresh is a full + // buffer scan, so the interval deliberately stays coarse. + constexpr std::chrono::milliseconds SearchMidOutputRefreshInterval{ 500 }; + static winrt::Microsoft::Terminal::Core::OptionalColor OptionalFromColor(const til::color& c) noexcept { Core::OptionalColor result; @@ -241,6 +248,27 @@ namespace winrt::Microsoft::Terminal::Control::implementation _terminal->UpdatePatternsUnderLock(); }); + // Companion to outputIdle for an open search: outputIdle is debounced, + // so sustained output starves it and a live search would never see new + // matches. This one is throttled without debounce (fires at most once + // per interval while being poked) and is only armed from the output + // handler while a search is active. TermControl re-checks the search + // box state on the UI thread, so a trailing fire after the search + // closed is a no-op. + shared->midOutputSearchRefresh = std::make_unique>( + til::throttled_func_options{ + .delay = SearchMidOutputRefreshInterval, + .trailing = true, + }, + [weakThis = get_weak(), dispatcher = _dispatcher]() { + dispatcher.TryEnqueue(DispatcherQueuePriority::Normal, [weakThis]() { + if (const auto self = weakThis.get(); self && !self->_IsClosing()) + { + self->SearchRefreshNeeded.raise(*self, nullptr); + } + }); + }); + // If you rapidly show/hide Windows Terminal, something about GotFocus()/LostFocus() gets broken. // We'll then receive easily 10+ such calls from WinUI the next time the application is shown. shared->focusChanged = std::make_unique>( @@ -334,6 +362,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation // we're re-attached to a new control (on a possibly new UI thread). const auto shared = _shared.lock(); shared->outputIdle.reset(); + shared->midOutputSearchRefresh.reset(); shared->updateScrollBar.reset(); } @@ -1314,7 +1343,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation // TermControl will call Search() once the OutputIdle even fires after 100ms. // Until then we need to hide the now-stale search results from the renderer. - ClearSearch(); + // Never select the focused result here: the spans predate the reflow and + // no longer describe this buffer's geometry. + _clearSearchImpl(false); const auto shared = _shared.lock_shared(); if (shared->outputIdle) { @@ -2240,8 +2271,13 @@ namespace winrt::Microsoft::Terminal::Control::implementation _terminal->SetSearchHighlightFocused(gsl::narrow(std::max(0, _searcher.CurrentMatch()))); _renderer->TriggerSearchHighlight(oldResults); + _searchGeneration++; } + // A non-empty query means mid-output refreshes must stay armed; an + // empty one returns the connection thread to the zero-cost path. + _searchActive.store(!request.Text.empty(), std::memory_order_relaxed); + if (request.ScrollIntoView) { _terminal->ScrollToSearchHighlight(request.ScrollOffset); @@ -2283,27 +2319,59 @@ namespace winrt::Microsoft::Terminal::Control::implementation return -1; } + // Monotonic identity of the search state. Consumers that only need to know + // "did the result set, the focused match, or the cleared state change" + // (the scrollbar overview) can compare this instead of result vectors. + uint64_t ControlCore::SearchStateGeneration() const noexcept + { + return _searchGeneration; + } + + // Identity of the current buffer content. Any mutation changes it: output, + // scrollback eviction, and resize/reflow (each new TextBuffer starts in + // its own id-space), as does switching the main/alt buffer. + uint64_t ControlCore::BufferMutationId() const + { + const auto lock = _terminal->LockForReading(); + return _terminal->GetTextBuffer().GetLastMutationId(); + } + void ControlCore::ClearSearch() + { + _clearSearchImpl(true); + } + + // `selectFocusedResult` turns the focused match into a selection (GH#19358) + // so that closing the search box keeps the user's place in the buffer. + // Invalidation paths (resize/reflow) must pass false: at that point the + // stored spans describe the pre-reflow buffer, and converting them against + // the new buffer's geometry would select arbitrary coordinates. + void ControlCore::_clearSearchImpl(const bool selectFocusedResult) { const auto lock = _terminal->LockForWriting(); // GH #19358: select the focused search result before clearing search - if (const auto focusedSearchResult = _terminal->GetSearchHighlightFocused()) + if (selectFocusedResult) { - // search results are buffer-relative, whereas the selection functions expect viewport-relative coordinates - const auto scrollOffset{ _terminal->GetScrollOffset() }; - const auto startPos = til::point{ focusedSearchResult->start.x, focusedSearchResult->start.y - scrollOffset }; - const auto endPos = til::point{ focusedSearchResult->end.x, focusedSearchResult->end.y - scrollOffset }; - - _terminal->SetSelectionAnchor(startPos); - _terminal->SetSelectionEnd(endPos); - _renderer->TriggerSelection(); + if (const auto focusedSearchResult = _terminal->GetSearchHighlightFocused()) + { + // search results are buffer-relative, whereas the selection functions expect viewport-relative coordinates + const auto scrollOffset{ _terminal->GetScrollOffset() }; + const auto startPos = til::point{ focusedSearchResult->start.x, focusedSearchResult->start.y - scrollOffset }; + const auto endPos = til::point{ focusedSearchResult->end.x, focusedSearchResult->end.y - scrollOffset }; + + _terminal->SetSelectionAnchor(startPos); + _terminal->SetSelectionEnd(endPos); + _renderer->TriggerSelection(); + } } _terminal->SetSearchHighlights({}); _terminal->SetSearchHighlightFocused(0); _renderer->TriggerSearchHighlight(_searcher.Results()); _searcher = {}; + _searchGeneration++; + _searchActive.store(false, std::memory_order_relaxed); } void ControlCore::Close() @@ -3078,6 +3146,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation { (*shared->outputIdle)(); } + + // outputIdle above is debounced, so sustained output postpones it + // indefinitely. While a search is active, additionally poke the + // non-debounced refresh cap so the open search converges against + // the mutating buffer. Search closed costs one relaxed load here. + if (_searchActive.load(std::memory_order_relaxed) && shared->midOutputSearchRefresh) + { + (*shared->midOutputSearchRefresh)(); + } } catch (...) { diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index 0ca53887a..39210a7be 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -256,6 +256,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation SearchResults Search(const SearchRequest& request); const std::vector& SearchResultRows() const noexcept; til::CoordType SearchCurrentMatchRow() const noexcept; + uint64_t SearchStateGeneration() const noexcept; + uint64_t BufferMutationId() const; void ClearSearch(); void LeftClickOnTerminal(const til::point terminalPosition, @@ -322,6 +324,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation til::typed_event RaiseNotice; til::typed_event TransparencyChanged; til::typed_event<> OutputIdle; + til::typed_event<> SearchRefreshNeeded; til::typed_event ShowWindowChanged; til::typed_event UpdateSelectionMarkers; til::typed_event OpenHyperlink; @@ -340,6 +343,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation struct SharedState { std::unique_ptr> outputIdle; + std::unique_ptr> midOutputSearchRefresh; std::unique_ptr> focusChanged; std::shared_ptr> updateScrollBar; }; @@ -350,6 +354,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool _setFontSizeUnderLock(float fontSize); void _updateFont(); void _refreshSizeUnderLock(); + void _clearSearchImpl(bool selectFocusedResult); void _updateSelectionUI(); bool _shouldTryUpdateSelection(const WORD vkey); @@ -454,6 +459,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation til::point _contextMenuBufferPosition{ 0, 0 }; Windows::Foundation::Collections::IVector _cachedQuickFixes{ nullptr }; ::Search _searcher; + // Bumped whenever the search result set, the focused match, or the + // cleared state changes. Cheap identity for consumers (the scrollbar + // overview) that need to know "did search state change" without + // comparing result vectors. + uint64_t _searchGeneration{ 0 }; + // True while a non-empty query is active. Read from the connection + // thread to decide whether mid-output search refreshes need to be + // scheduled at all; search closed must mean zero recurring work. + std::atomic _searchActive{ false }; std::optional::interval> _lastHoveredInterval; std::optional _leadingSurrogate; std::optional _lastHoveredCell; diff --git a/src/cascadia/TerminalControl/ControlCore.idl b/src/cascadia/TerminalControl/ControlCore.idl index 524ebb810..1477866ac 100644 --- a/src/cascadia/TerminalControl/ControlCore.idl +++ b/src/cascadia/TerminalControl/ControlCore.idl @@ -215,6 +215,7 @@ namespace Microsoft.Terminal.Control event Windows.Foundation.TypedEventHandler RaiseNotice; event Windows.Foundation.TypedEventHandler TransparencyChanged; event Windows.Foundation.TypedEventHandler OutputIdle; + event Windows.Foundation.TypedEventHandler SearchRefreshNeeded; event Windows.Foundation.TypedEventHandler UpdateSelectionMarkers; event Windows.Foundation.TypedEventHandler OpenHyperlink; event Windows.Foundation.TypedEventHandler CloseTerminalRequested; diff --git a/src/cascadia/TerminalControl/SearchUxHelpers.h b/src/cascadia/TerminalControl/SearchUxHelpers.h index 272ec72f8..8a0432005 100644 --- a/src/cascadia/TerminalControl/SearchUxHelpers.h +++ b/src/cascadia/TerminalControl/SearchUxHelpers.h @@ -79,4 +79,65 @@ namespace winTerm::Control::SearchUx } } } + + // Everything the scrollbar mark bitmap's content depends on. The bitmap + // is repainted only when one of these inputs changes; a scroll update + // that merely moves the thumb repaints nothing, which keeps scrolling + // cheap with very large result sets. The search core stays the source of + // truth — this is an identity of its state, not a copy of it. + struct ScrollbarMarkPaintState + { + double maximum{ 0 }; + double viewportSize{ 0 }; + int32_t widthPx{ 0 }; + int32_t heightPx{ 0 }; + bool genericMarks{ false }; + bool searchMarks{ false }; + uint64_t searchGeneration{ 0 }; + uint64_t bufferMutationId{ 0 }; + uint32_t searchPipColor{ 0 }; + + friend constexpr bool operator==(const ScrollbarMarkPaintState&, const ScrollbarMarkPaintState&) noexcept = default; + }; + + // Builds the paint state for one update. Inputs only participate while + // the category that consumes them renders: the buffer mutation id backs + // the generic marks (their content derives from buffer rows), and the pip + // color backs the search overview (searchGeneration covers its rows). + // Without this rule, an alt-screen application mutating the buffer would + // force a repaint per throttle tick even though the overview cannot + // change. + constexpr ScrollbarMarkPaintState MakeScrollbarMarkPaintState(const double maximum, + const double viewportSize, + const int32_t widthPx, + const int32_t heightPx, + const bool genericMarks, + const bool searchMarks, + const uint64_t searchGeneration, + const uint64_t bufferMutationId, + const uint32_t searchPipColor) noexcept + { + return ScrollbarMarkPaintState{ + .maximum = maximum, + .viewportSize = viewportSize, + .widthPx = widthPx, + .heightPx = heightPx, + .genericMarks = genericMarks, + .searchMarks = searchMarks, + .searchGeneration = searchGeneration, + .bufferMutationId = genericMarks ? bufferMutationId : 0, + .searchPipColor = searchMarks ? searchPipColor : 0, + }; + } + + // A repaint is needed when there is no previously painted state (first + // paint, or the canvas was collapsed/invalidated) or when any input + // changed. `hasPrevious`/`previous` mirror std::optional without forcing + // the header to include it. + constexpr bool ShouldRepaintScrollbarMarks(const bool hasPrevious, + const ScrollbarMarkPaintState& previous, + const ScrollbarMarkPaintState& next) noexcept + { + return !hasPrevious || !(previous == next); + } } diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 64558f611..961e83671 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -33,6 +33,13 @@ constexpr const auto ScrollBarUpdateInterval = std::chrono::milliseconds(8); // Coalesce bursts of OSC 133 and reflow notifications into one visible-list update. constexpr const auto CommandTimelineUpdateInterval = std::chrono::milliseconds(16); + +// Rate limit for live-typing searches. Every executed search is a full +// scrollback scan on the UI thread, so keystrokes arriving faster than this +// collapse into one trailing search that reads the box's latest state. The +// leading edge still fires immediately, keeping single keystrokes as +// responsive as an unthrottled search. +constexpr const auto SearchTypingCoalesceInterval = std::chrono::milliseconds(50); // A short debounce marks the end of high-precision wheel/trackpad input and // discards any partial-row remainder without running a continuous animation. @@ -320,6 +327,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation _revokers.RaiseNotice = _core.RaiseNotice(winrt::auto_revoke, { get_weak(), &TermControl::_coreRaisedNotice }); _revokers.HoveredHyperlinkChanged = _core.HoveredHyperlinkChanged(winrt::auto_revoke, { get_weak(), &TermControl::_hoveredHyperlinkChanged }); _revokers.OutputIdle = _core.OutputIdle(winrt::auto_revoke, { get_weak(), &TermControl::_coreOutputIdle }); + _revokers.SearchRefreshNeeded = _core.SearchRefreshNeeded(winrt::auto_revoke, { get_weak(), &TermControl::_coreSearchRefreshNeeded }); _revokers.UpdateSelectionMarkers = _core.UpdateSelectionMarkers(winrt::auto_revoke, { get_weak(), &TermControl::_updateSelectionMarkers }); _revokers.coreOpenHyperlink = _core.OpenHyperlink(winrt::auto_revoke, { get_weak(), &TermControl::_HyperlinkHandler }); _revokers.interactivityOpenHyperlink = _interactivity.OpenHyperlink(winrt::auto_revoke, { get_weak(), &TermControl::_HyperlinkHandler }); @@ -404,6 +412,24 @@ namespace winrt::Microsoft::Terminal::Control::implementation if (auto control{ weakThis.get() }; control && !control->_IsClosing() && control->_commandTimelineOpen) { control->_refreshCommandTimeline(); + } + }); + + // The callback deliberately captures no query state: it re-reads the + // search box at fire time, so the latest typed text always wins and a + // fire that lands after the box closed finds IsOpen() false and does + // nothing. + _deferredSearch = std::make_shared>( + dispatcher, + til::throttled_func_options{ + .delay = SearchTypingCoalesceInterval, + .leading = true, + .trailing = true, + }, + [weakThis = get_weak()]() { + if (auto control{ weakThis.get() }; control && !control->_IsClosing()) + { + control->_runLiveSearch(); } }); _revokers.CommandTimelineChanged = _core.CommandTimelineChanged(winrt::auto_revoke, { get_weak(), &TermControl::_coreCommandTimelineChanged }); @@ -612,6 +638,31 @@ namespace winrt::Microsoft::Terminal::Control::implementation return; } + // The bitmap's content is independent of the thumb position. If + // none of its inputs changed since the last paint, this update + // only moved the thumb — skip the repaint so plain scrolling + // never re-enumerates search results or mark rows. + const auto core = winrt::get_self(_core); + const til::color searchPipColor{ core->ForegroundColor() }; + const auto paintState = winTerm::Control::SearchUx::MakeScrollbarMarkPaintState( + update.newMaximum, + update.newViewportSize, + scrollBarWidthInPx, + scrollBarHeightInPx, + showGenericMarks, + showSearchMarks, + core->SearchStateGeneration(), + showGenericMarks ? core->BufferMutationId() : 0, + searchPipColor); + if (_scrollBarCanvasVisible && + !winTerm::Control::SearchUx::ShouldRepaintScrollbarMarks( + _lastScrollBarMarkPaint.has_value(), + _lastScrollBarMarkPaint.value_or(winTerm::Control::SearchUx::ScrollbarMarkPaintState{}), + paintState)) + { + return; + } + const auto canvas = FindName(L"ScrollBarCanvas").as(); auto source = canvas.Source().try_as(); @@ -690,10 +741,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation if (showSearchMarks) { - const auto core = winrt::get_self(_core); const auto& searchMatches = core->SearchResultRows(); const auto currentRow = core->SearchCurrentMatchRow(); - const auto color = core->ForegroundColor(); + const auto color = searchPipColor; const auto rightAlignedOffset = (scrollBarWidthInPx - pipWidth) * sizeof(til::color); // The current match widens into the empty center stripe, so it // stands out from ordinary matches without introducing colors. @@ -709,6 +759,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation source.Invalidate(); canvas.Visibility(Visibility::Visible); _scrollBarCanvasVisible = true; + _lastScrollBarMarkPaint = paintState; } else { @@ -720,6 +771,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation // when nothing was ever drawn, so it can run on every scrollbar update. void TermControl::_collapseScrollBarCanvas() { + // Whatever pixels the bitmap holds no longer match a painted state; + // the next visible update must repaint unconditionally. + _lastScrollBarMarkPaint.reset(); if (!_scrollBarCanvasVisible) { return; @@ -862,22 +916,51 @@ namespace winrt::Microsoft::Terminal::Control::implementation // Return Value: // - void TermControl::_SearchChanged(const winrt::hstring& text, - const bool goForward, - const bool caseSensitive, - const bool regularExpression) + const bool /*goForward*/, + const bool /*caseSensitive*/, + const bool /*regularExpression*/) { - if (_searchBox && _searchBox->IsOpen()) + if (!_searchBox || !_searchBox->IsOpen()) { - _handleSearchResults(_core.Search(SearchRequest{ - .Text = text, - .GoForward = goForward, - .CaseSensitive = caseSensitive, - .RegularExpression = regularExpression, - .ExecuteSearch = false, - .ScrollIntoView = true, - .ScrollOffset = _searchScrollOffset, - })); + return; + } + + // Clearing the query must converge immediately: run the empty search + // synchronously so highlights, pips and status vanish with the last + // erased character. A still-pending coalesced search is harmless — it + // re-reads the (now empty) box state when it fires. + if (text.empty()) + { + _runLiveSearch(); + return; + } + + // Every executed search scans the entire scrollback, so rapid typing + // is coalesced. Navigation (Enter / the buttons) doesn't go through + // this path: _Search always executes synchronously with the box's + // current text, so navigation can never act on a stale query. + _deferredSearch->Run(); + } + + // Performs a reset-only search from the search box's current state. Both + // the immediate empty-query path and the coalesced typing path land here, + // which is what guarantees that the newest state always wins. + void TermControl::_runLiveSearch() + { + if (!_searchBox || !_searchBox->IsOpen()) + { + return; } + + _handleSearchResults(_core.Search(SearchRequest{ + .Text = _searchBox->Text(), + .GoForward = _searchBox->GoForward(), + .CaseSensitive = _searchBox->CaseSensitive(), + .RegularExpression = _searchBox->RegularExpression(), + .ExecuteSearch = false, + .ScrollIntoView = true, + .ScrollOffset = _searchScrollOffset, + })); } // Method Description: @@ -4955,6 +5038,14 @@ namespace winrt::Microsoft::Terminal::Control::implementation _refreshSearch(); } + // Raised by the core while output streams continuously and a search is + // active; OutputIdle alone would never fire in that state. _refreshSearch + // re-checks the search box, so a late event after close is a no-op. + void TermControl::_coreSearchRefreshNeeded(const IInspectable& /*sender*/, const IInspectable& /*args*/) + { + _refreshSearch(); + } + void TermControl::OwningHwnd(uint64_t owner) { _core.OwningHwnd(owner); diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index d242533fc..c1f654643 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -4,6 +4,7 @@ #pragma once #include "SearchBoxControl.h" +#include "SearchUxHelpers.h" #include "TermControl.g.h" #include "../../buffer/out/search.h" #include "../../cascadia/TerminalCore/Terminal.hpp" @@ -309,6 +310,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation std::shared_ptr> _updateScrollBar; std::shared_ptr> _updateCommandTimeline; + // Coalesces live-typing searches. The callback reads the search box's + // current state at fire time, so the latest query always wins and a + // stale queued query can never overwrite newer results. Pane-local by + // construction: every TermControl owns its own instance. + std::shared_ptr> _deferredSearch; bool _isInternalScrollBarUpdate; @@ -336,6 +342,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation winrt::hstring _restorePath; bool _showMarksInScrollbar{ false }; bool _scrollBarCanvasVisible{ false }; + // Inputs of the last painted scrollbar mark bitmap; nullopt when the + // canvas is collapsed or was never painted. Scroll updates that leave + // these unchanged skip the repaint entirely. + std::optional _lastScrollBarMarkPaint; bool _isBackgroundLight{ false }; bool _detached{ false }; @@ -477,6 +487,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation void _coreRaisedNotice(const IInspectable& s, const Control::NoticeEventArgs& args); void _coreWarningBell(const IInspectable& sender, const IInspectable& args); void _coreOutputIdle(const IInspectable& sender, const IInspectable& args); + void _coreSearchRefreshNeeded(const IInspectable& sender, const IInspectable& args); + void _runLiveSearch(); winrt::Windows::Foundation::Point _toPosInDips(const Core::Point terminalCellPos); void _throttledUpdateScrollbar(const ScrollBarUpdate& update); @@ -510,6 +522,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation Control::ControlCore::RaiseNotice_revoker RaiseNotice; Control::ControlCore::HoveredHyperlinkChanged_revoker HoveredHyperlinkChanged; Control::ControlCore::OutputIdle_revoker OutputIdle; + Control::ControlCore::SearchRefreshNeeded_revoker SearchRefreshNeeded; Control::ControlCore::UpdateSelectionMarkers_revoker UpdateSelectionMarkers; Control::ControlCore::OpenHyperlink_revoker coreOpenHyperlink; Control::ControlCore::TitleChanged_revoker TitleChanged; diff --git a/src/cascadia/TerminalCore/Terminal.cpp b/src/cascadia/TerminalCore/Terminal.cpp index 72197b422..d759c96b1 100644 --- a/src/cascadia/TerminalCore/Terminal.cpp +++ b/src/cascadia/TerminalCore/Terminal.cpp @@ -1325,10 +1325,12 @@ void Microsoft::Terminal::Core::Terminal::SetClearQuickFixCallback(std::function // Method Description: // - Stores the search highlighted regions in the terminal -void Terminal::SetSearchHighlights(const std::vector& highlights) noexcept +// - Takes the vector by value so that clearing the highlights releases the +// previous allocation instead of retaining its capacity indefinitely. +void Terminal::SetSearchHighlights(std::vector highlights) noexcept { _assertLocked(); - _searchHighlights = highlights; + _searchHighlights = std::move(highlights); } // Method Description: diff --git a/src/cascadia/TerminalCore/Terminal.hpp b/src/cascadia/TerminalCore/Terminal.hpp index 904c6b482..239638e09 100644 --- a/src/cascadia/TerminalCore/Terminal.hpp +++ b/src/cascadia/TerminalCore/Terminal.hpp @@ -260,7 +260,7 @@ class Microsoft::Terminal::Core::Terminal final : void SetSearchMissingCommandCallback(std::function pfn) noexcept; void SetClearQuickFixCallback(std::function pfn) noexcept; void SetWindowSizeChangedCallback(std::function pfn) noexcept; - void SetSearchHighlights(const std::vector& highlights) noexcept; + void SetSearchHighlights(std::vector highlights) noexcept; void SetSearchHighlightFocused(size_t focusedIdx) noexcept; void ScrollToSearchHighlight(til::CoordType searchScrollOffset); diff --git a/src/cascadia/TerminalCore/TerminalApi.cpp b/src/cascadia/TerminalCore/TerminalApi.cpp index e11d6b81a..096b71806 100644 --- a/src/cascadia/TerminalCore/TerminalApi.cpp +++ b/src/cascadia/TerminalCore/TerminalApi.cpp @@ -254,6 +254,12 @@ void Terminal::UseAlternateScreenBuffer(const TextAttribute& attrs) ClearSelection(); + // Any stored search highlights refer to the main buffer; rendering them + // against the alt buffer would paint coordinates from the wrong buffer. + // The next search refresh recomputes them against the active buffer. + SetSearchHighlights({}); + SetSearchHighlightFocused(0); + // Create a new alt buffer _altBuffer = std::make_unique(_altBufferSize, attrs, @@ -308,6 +314,12 @@ void Terminal::UseMainScreenBuffer() ClearSelection(); + // The stored search highlights were computed against the alt buffer; + // drop them so they cannot paint onto the main buffer. The next search + // refresh recomputes them against the active buffer. + SetSearchHighlights({}); + SetSearchHighlightFocused(0); + _mainBuffer->SetAsActiveBuffer(true); if (_deferredResize.has_value()) diff --git a/src/cascadia/UnitTests_Control/ControlCoreTests.cpp b/src/cascadia/UnitTests_Control/ControlCoreTests.cpp index 7dd12da76..9a5601810 100644 --- a/src/cascadia/UnitTests_Control/ControlCoreTests.cpp +++ b/src/cascadia/UnitTests_Control/ControlCoreTests.cpp @@ -48,6 +48,18 @@ namespace ControlUnitTests TEST_METHOD(TestSearchSameRowOccurrences); TEST_METHOD(TestSearchUxHelperContracts); + TEST_METHOD(TestSearchBufferMutationRefreshesResults); + TEST_METHOD(TestSearchScrollbackEvictionSafety); + TEST_METHOD(TestSearchReflowInvalidationAndNoStraySelection); + TEST_METHOD(TestSearchAltBufferTransitions); + TEST_METHOD(TestSearchStateGenerationSemantics); + TEST_METHOD(TestSearchUnicodeWideSpans); + TEST_METHOD(TestScrollbarMarkPaintStateContracts); + + BEGIN_TEST_METHOD(TestSearchScanPerfSmoke) + TEST_METHOD_PROPERTY(L"TestTimeout", L"0:2:0") + END_TEST_METHOD() + TEST_METHOD(TestSelectCommandSimple); TEST_METHOD(TestSelectOutputSimple); TEST_METHOD(TestCommandContext); @@ -700,6 +712,450 @@ namespace ControlUnitTests VERIFY_ARE_EQUAL(9, rows[2]); } + void ControlCoreTests::TestSearchBufferMutationRefreshesResults() + { + auto [settings, conn] = _createSettingsAndConnection(); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + const auto search = [&core](const bool executeSearch) { + return core->Search(Control::SearchRequest{ + .Text = L"ERROR", + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = executeSearch, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + }; + + conn->WriteInput(winrt_wstring_to_array_view(L"ERROR one\r\n")); + conn->WriteInput(winrt_wstring_to_array_view(L"filler\r\n")); + conn->WriteInput(winrt_wstring_to_array_view(L"ERROR two\r\n")); + + auto results = search(false); + VERIFY_ARE_EQUAL(2, results.TotalMatches); + VERIFY_IS_TRUE(results.SearchInvalidated); + + Log::Comment(L"Navigate to the second match so the focused span is mid-list"); + results = search(true); + VERIFY_ARE_EQUAL(1, results.CurrentMatch); + + Log::Comment(L"New output invalidates the search on the next refresh"); + conn->WriteInput(winrt_wstring_to_array_view(L"ERROR three\r\n")); + results = search(false); + VERIFY_IS_TRUE(results.SearchInvalidated); + VERIFY_ARE_EQUAL(3, results.TotalMatches); + + Log::Comment(L"Matches appended below keep the focused match anchored, not reset to 1/n"); + VERIFY_ARE_EQUAL(1, results.CurrentMatch); + + Log::Comment(L"A repeated refresh without buffer mutation is a no-op"); + results = search(false); + VERIFY_IS_FALSE(results.SearchInvalidated); + VERIFY_ARE_EQUAL(3, results.TotalMatches); + VERIFY_ARE_EQUAL(1, results.CurrentMatch); + } + + void ControlCoreTests::TestSearchScrollbackEvictionSafety() + { + auto [settings, conn] = _createSettingsAndConnection(); + Log::Comment(L"A tiny history forces scrollback eviction quickly"); + settings->HistorySize(5); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + const auto search = [&core](const winrt::hstring& text) { + return core->Search(Control::SearchRequest{ + .Text = text, + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = false, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + }; + + conn->WriteInput(winrt_wstring_to_array_view(L"EVICT_ME marker\r\n")); + auto results = search(L"EVICT_ME"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + + Log::Comment(L"Fill the buffer far past its 25-row capacity"); + for (auto i = 0; i < 40; ++i) + { + conn->WriteInput(winrt_wstring_to_array_view(L"filler line\r\n")); + } + conn->WriteInput(winrt_wstring_to_array_view(L"KEEP_ME marker")); + + Log::Comment(L"The evicted row is gone from the results, not stale"); + results = search(L"EVICT_ME"); + VERIFY_IS_TRUE(results.SearchInvalidated); + VERIFY_ARE_EQUAL(0, results.TotalMatches); + VERIFY_IS_TRUE(core->SearchResultRows().empty()); + + Log::Comment(L"Surviving content is found within the current buffer bounds"); + results = search(L"KEEP_ME"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(1u, core->SearchResultRows().size()); + const auto bufferHeight = core->_terminal->GetBufferHeight(); + for (const auto& span : core->SearchResultRows()) + { + VERIFY_IS_TRUE(span.start.y >= 0); + VERIFY_IS_TRUE(span.end.y < bufferHeight); + } + VERIFY_IS_TRUE(core->SearchCurrentMatchRow() >= 0); + VERIFY_IS_TRUE(core->SearchCurrentMatchRow() < bufferHeight); + } + + void ControlCoreTests::TestSearchReflowInvalidationAndNoStraySelection() + { + auto [settings, conn] = _createSettingsAndConnection(); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + const auto search = [&core](const winrt::hstring& text) { + return core->Search(Control::SearchRequest{ + .Text = text, + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = false, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + }; + + Log::Comment(L"A 60-column line wraps in the 30-column viewport"); + conn->WriteInput(winrt_wstring_to_array_view(L"NEEDLE aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa NEEDLE bbbbbbbbbb\r\n")); + + auto results = search(L"NEEDLE"); + VERIFY_ARE_EQUAL(2, results.TotalMatches); + + Log::Comment(L"Resizing reflows the buffer and hides the stale results"); + core->SizeChanged(180, 380); + VERIFY_IS_TRUE(core->SearchResultRows().empty()); + + Log::Comment(L"The invalidation must not convert pre-reflow spans into a selection"); + VERIFY_IS_FALSE(core->HasSelection()); + + Log::Comment(L"A fresh search maps onto the reflowed 20-column geometry"); + results = search(L"NEEDLE"); + VERIFY_IS_TRUE(results.SearchInvalidated); + VERIFY_ARE_EQUAL(2, results.TotalMatches); + for (const auto& span : core->SearchResultRows()) + { + VERIFY_IS_TRUE(span.start.x >= 0); + VERIFY_IS_TRUE(span.start.x < 20); + } + + Log::Comment(L"Growing the pane again keeps the search recomputable"); + core->SizeChanged(270, 380); + VERIFY_IS_TRUE(core->SearchResultRows().empty()); + VERIFY_IS_FALSE(core->HasSelection()); + results = search(L"NEEDLE"); + VERIFY_ARE_EQUAL(2, results.TotalMatches); + } + + void ControlCoreTests::TestSearchAltBufferTransitions() + { + auto [settings, conn] = _createSettingsAndConnection(); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + const auto search = [&core](const winrt::hstring& text) { + return core->Search(Control::SearchRequest{ + .Text = text, + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = false, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + }; + const auto highlightCount = [&core]() { + const auto lock = core->_terminal->LockForReading(); + return core->_terminal->GetSearchHighlights().size(); + }; + + conn->WriteInput(winrt_wstring_to_array_view(L"MAIN_NEEDLE here\r\n")); + auto results = search(L"MAIN_NEEDLE"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(1u, highlightCount()); + + Log::Comment(L"Entering the alt buffer drops the main-buffer highlight spans"); + conn->WriteInput(winrt_wstring_to_array_view(L"\x1b[?1049h")); + VERIFY_ARE_EQUAL(0u, highlightCount()); + + Log::Comment(L"Search follows the active buffer: main content is not visible here"); + conn->WriteInput(winrt_wstring_to_array_view(L"ALT_NEEDLE content")); + results = search(L"MAIN_NEEDLE"); + VERIFY_ARE_EQUAL(0, results.TotalMatches); + results = search(L"ALT_NEEDLE"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + const auto viewHeight = core->_terminal->GetViewport().Height(); + for (const auto& span : core->SearchResultRows()) + { + VERIFY_IS_TRUE(span.start.y >= 0); + VERIFY_IS_TRUE(span.end.y < viewHeight); + } + + Log::Comment(L"Leaving the alt buffer drops the alt-buffer highlight spans"); + conn->WriteInput(winrt_wstring_to_array_view(L"\x1b[?1049l")); + VERIFY_ARE_EQUAL(0u, highlightCount()); + + Log::Comment(L"The main buffer content is searchable again"); + results = search(L"MAIN_NEEDLE"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + results = search(L"ALT_NEEDLE"); + VERIFY_ARE_EQUAL(0, results.TotalMatches); + } + + void ControlCoreTests::TestSearchStateGenerationSemantics() + { + auto [settings, conn] = _createSettingsAndConnection(); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + const auto search = [&core](const winrt::hstring& text, const bool executeSearch) { + return core->Search(Control::SearchRequest{ + .Text = text, + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = executeSearch, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + }; + + conn->WriteInput(winrt_wstring_to_array_view(L"error one\r\n")); + conn->WriteInput(winrt_wstring_to_array_view(L"error two\r\n")); + + Log::Comment(L"A new query advances the generation and arms mid-output refreshes"); + const auto genInitial = core->SearchStateGeneration(); + search(L"error", false); + const auto genAfterSearch = core->SearchStateGeneration(); + VERIFY_IS_TRUE(genAfterSearch > genInitial); + VERIFY_IS_TRUE(core->_searchActive.load()); + + Log::Comment(L"A no-op refresh (same query, no mutation) leaves the generation alone"); + search(L"error", false); + VERIFY_ARE_EQUAL(genAfterSearch, core->SearchStateGeneration()); + + Log::Comment(L"Navigation changes the focused match and advances the generation"); + search(L"error", true); + const auto genAfterNavigation = core->SearchStateGeneration(); + VERIFY_IS_TRUE(genAfterNavigation > genAfterSearch); + + Log::Comment(L"Buffer mutation changes the mutation id and the next refresh advances"); + const auto mutationBefore = core->BufferMutationId(); + conn->WriteInput(winrt_wstring_to_array_view(L"error three\r\n")); + VERIFY_IS_TRUE(core->BufferMutationId() != mutationBefore); + search(L"error", false); + const auto genAfterMutation = core->SearchStateGeneration(); + VERIFY_IS_TRUE(genAfterMutation > genAfterNavigation); + + Log::Comment(L"An empty query disarms the mid-output refresh path"); + search(L"", false); + VERIFY_IS_FALSE(core->_searchActive.load()); + + Log::Comment(L"Clearing the search advances the generation and stays disarmed"); + search(L"error", false); + VERIFY_IS_TRUE(core->_searchActive.load()); + const auto genBeforeClear = core->SearchStateGeneration(); + core->ClearSearch(); + VERIFY_IS_TRUE(core->SearchStateGeneration() > genBeforeClear); + VERIFY_IS_FALSE(core->_searchActive.load()); + VERIFY_IS_TRUE(core->SearchResultRows().empty()); + } + + void ControlCoreTests::TestSearchUnicodeWideSpans() + { + auto [settings, conn] = _createSettingsAndConnection(); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + const auto search = [&core](const winrt::hstring& text) { + return core->Search(Control::SearchRequest{ + .Text = text, + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = false, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + }; + const auto spanWidth = [&core]() { + const auto& spans = core->SearchResultRows(); + VERIFY_ARE_EQUAL(1u, spans.size()); + return spans[0].end.x - spans[0].start.x; + }; + + conn->WriteInput(winrt_wstring_to_array_view(L"錯誤 and 错误\r\n")); + conn->WriteInput(winrt_wstring_to_array_view(L"エラー plus 오류\r\n")); + conn->WriteInput(winrt_wstring_to_array_view(L"café 👍 done\r\n")); + + Log::Comment(L"Traditional Chinese: two wide glyphs cover four cells"); + auto results = search(L"錯誤"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(4, spanWidth()); + + Log::Comment(L"Simplified Chinese matches independently"); + results = search(L"错误"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(4, spanWidth()); + + Log::Comment(L"Japanese katakana: three wide glyphs cover six cells"); + results = search(L"エラー"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(6, spanWidth()); + + Log::Comment(L"Korean hangul: two wide glyphs cover four cells"); + results = search(L"오류"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(4, spanWidth()); + + Log::Comment(L"Accented Latin stays narrow"); + results = search(L"café"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(4, spanWidth()); + + Log::Comment(L"An emoji surrogate pair occupies two cells"); + results = search(L"👍"); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(2, spanWidth()); + + Log::Comment(L"Navigation across wide-character matches stays in range"); + results = core->Search(Control::SearchRequest{ + .Text = L"錯誤", + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = false, + .ExecuteSearch = true, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + VERIFY_ARE_EQUAL(1, results.TotalMatches); + VERIFY_ARE_EQUAL(0, results.CurrentMatch); + } + + void ControlCoreTests::TestScrollbarMarkPaintStateContracts() + { + using namespace winTerm::Control::SearchUx; + + const auto base = MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, true, 7, 42, 0xAABBCC); + + Log::Comment(L"The first paint always happens"); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(false, ScrollbarMarkPaintState{}, base)); + + Log::Comment(L"An identical state skips the repaint"); + const auto same = MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, true, 7, 42, 0xAABBCC); + VERIFY_IS_FALSE(ShouldRepaintScrollbarMarks(true, base, same)); + + Log::Comment(L"Geometry, flag, and generation changes each force a repaint"); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(101.0, 20.0, 12, 600, true, true, 7, 42, 0xAABBCC))); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 21.0, 12, 600, true, true, 7, 42, 0xAABBCC))); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 13, 600, true, true, 7, 42, 0xAABBCC))); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 12, 601, true, true, 7, 42, 0xAABBCC))); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, false, true, 7, 42, 0xAABBCC))); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, false, 7, 42, 0xAABBCC))); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, true, 8, 42, 0xAABBCC))); + + Log::Comment(L"With generic marks shown, buffer mutations force a repaint"); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, true, 7, 43, 0xAABBCC))); + + Log::Comment(L"Without generic marks, buffer mutations alone do not repaint"); + const auto searchOnlyA = MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, false, true, 7, 42, 0xAABBCC); + const auto searchOnlyB = MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, false, true, 7, 999, 0xAABBCC); + VERIFY_IS_FALSE(ShouldRepaintScrollbarMarks(true, searchOnlyA, searchOnlyB)); + + Log::Comment(L"The pip color participates only while search pips render"); + VERIFY_IS_TRUE(ShouldRepaintScrollbarMarks(true, base, MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, true, 7, 42, 0x112233))); + const auto genericOnlyA = MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, false, 7, 42, 0xAABBCC); + const auto genericOnlyB = MakeScrollbarMarkPaintState(100.0, 20.0, 12, 600, true, false, 7, 42, 0x112233); + VERIFY_IS_FALSE(ShouldRepaintScrollbarMarks(true, genericOnlyA, genericOnlyB)); + } + + void ControlCoreTests::TestSearchScanPerfSmoke() + { + auto [settings, conn] = _createSettingsAndConnection(); + settings->HistorySize(9001); + auto core = createCore(*settings, *conn); + VERIFY_IS_NOT_NULL(core); + _standardInit(core); + + // Deterministic content; sized to stay comfortably inside CI budgets. + // For deeper local measurements set WINTERM_SEARCH_BENCH_LINES (the + // buffer caps at the configured history size). + uint32_t lineCount = 2000; + { + wchar_t buffer[16]{}; + if (GetEnvironmentVariableW(L"WINTERM_SEARCH_BENCH_LINES", buffer, ARRAYSIZE(buffer)) > 0) + { + lineCount = std::max(1ul, static_cast(wcstoul(buffer, nullptr, 10))); + } + } + + std::wstring chunk; + uint32_t written = 0; + while (written < lineCount) + { + chunk.clear(); + for (auto i = 0; i < 100 && written < lineCount; ++i, ++written) + { + fmt::format_to(std::back_inserter(chunk), L"INFO request={} ERROR sample WARN payload\r\n", written); + } + conn->WriteInput(winrt_wstring_to_array_view(chunk)); + } + + const auto measure = [&core](const winrt::hstring& text, const bool regex) { + const auto start = std::chrono::steady_clock::now(); + const auto results = core->Search(Control::SearchRequest{ + .Text = text, + .GoForward = true, + .CaseSensitive = false, + .RegularExpression = regex, + .ExecuteSearch = false, + .ScrollIntoView = false, + .ScrollOffset = 0, + }); + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - start); + Log::Comment(fmt::format(L"query='{}' regex={} matches={} elapsed={} us", + std::wstring_view{ text }, + regex ? 1 : 0, + results.TotalMatches, + elapsed.count()) + .c_str()); + return results; + }; + + Log::Comment(L"No timing assertions: CI validates behavior, the log captures cost"); + VERIFY_IS_TRUE(measure(L"e", false).TotalMatches > 0); + VERIFY_IS_TRUE(measure(L"er", false).TotalMatches > 0); + VERIFY_IS_TRUE(measure(L"error", false).TotalMatches > 0); + VERIFY_IS_TRUE(measure(L"ERROR", false).TotalMatches > 0); + VERIFY_ARE_EQUAL(0, measure(L"THIS_STRING_DOES_NOT_EXIST_123456", false).TotalMatches); + VERIFY_IS_TRUE(measure(L"ERROR|WARN", true).TotalMatches > 0); + + Log::Comment(L"An invalid regex reports the invalid state and holds no results"); + const auto invalid = measure(L"[", true); + VERIFY_IS_TRUE(invalid.SearchRegexInvalid); + VERIFY_IS_TRUE(core->SearchResultRows().empty()); + + core->ClearSearch(); + VERIFY_IS_TRUE(core->SearchResultRows().empty()); + } + static void _writePrompt(const winrt::com_ptr& conn, const std::wstring_view& path) { conn->WriteInput(winrt_wstring_to_array_view(L"\x1b]133;D\x7")); diff --git a/src/cascadia/WindowsTerminal/WindowsTerminal.rc b/src/cascadia/WindowsTerminal/WindowsTerminal.rc index a70b6db80..cdec15d2b 100644 --- a/src/cascadia/WindowsTerminal/WindowsTerminal.rc +++ b/src/cascadia/WindowsTerminal/WindowsTerminal.rc @@ -83,8 +83,8 @@ IDI_APPICON_HC_WHITE ICON "..\\..\\..\\res\\terminal\\imag #if defined(WT_BRANDING_WINTERM) 1 VERSIONINFO - FILEVERSION 1,3,2,0 - PRODUCTVERSION 1,3,2,0 + FILEVERSION 1,3,3,0 + PRODUCTVERSION 1,3,3,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -101,12 +101,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Terminal Host\0" - VALUE "FileVersion", "1.3.2.0\0" + VALUE "FileVersion", "1.3.3.0\0" VALUE "InternalName", "WindowsTerminal\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" VALUE "OriginalFilename", "WindowsTerminal.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.3.2\0" + VALUE "ProductVersion", "1.3.3\0" END END BLOCK "VarFileInfo" diff --git a/src/cascadia/wt/wt.rc b/src/cascadia/wt/wt.rc index fee4e9305..b500a652e 100644 --- a/src/cascadia/wt/wt.rc +++ b/src/cascadia/wt/wt.rc @@ -58,8 +58,8 @@ IDI_APPICON ICON "..\\..\\..\\res\\terminal.ico" #if defined(WT_BRANDING_WINTERM) 1 VERSIONINFO - FILEVERSION 1,3,2,0 - PRODUCTVERSION 1,3,2,0 + FILEVERSION 1,3,3,0 + PRODUCTVERSION 1,3,3,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -76,12 +76,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Launcher\0" - VALUE "FileVersion", "1.3.2.0\0" + VALUE "FileVersion", "1.3.3.0\0" VALUE "InternalName", "winTerm\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors. Portions copyright Microsoft Corporation.\0" VALUE "OriginalFilename", "winTerm.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.3.2\0" + VALUE "ProductVersion", "1.3.3\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm-tools/winterm-shim/winterm-shim.rc b/src/winterm-tools/winterm-shim/winterm-shim.rc index a150d1da3..a7e1555a1 100644 --- a/src/winterm-tools/winterm-shim/winterm-shim.rc +++ b/src/winterm-tools/winterm-shim/winterm-shim.rc @@ -4,8 +4,8 @@ #include 1 VERSIONINFO - FILEVERSION 1,3,2,0 - PRODUCTVERSION 1,3,2,0 + FILEVERSION 1,3,3,0 + PRODUCTVERSION 1,3,3,0 FILEFLAGSMASK 0x3fL #ifdef _DEBUG FILEFLAGS VS_FF_DEBUG @@ -22,12 +22,12 @@ BEGIN BEGIN VALUE "CompanyName", "helloThisWorld\0" VALUE "FileDescription", "winTerm Shell Integration Helper\0" - VALUE "FileVersion", "1.3.2.0\0" + VALUE "FileVersion", "1.3.3.0\0" VALUE "InternalName", "winterm-shim\0" VALUE "LegalCopyright", "Copyright (c) winTerm contributors.\0" VALUE "OriginalFilename", "winterm-shim.exe\0" VALUE "ProductName", "winTerm\0" - VALUE "ProductVersion", "1.3.2\0" + VALUE "ProductVersion", "1.3.3\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm/Branding/ReleaseMetadata.h b/src/winterm/Branding/ReleaseMetadata.h index 83791731b..5252c3f3d 100644 --- a/src/winterm/Branding/ReleaseMetadata.h +++ b/src/winterm/Branding/ReleaseMetadata.h @@ -25,7 +25,7 @@ namespace winTerm::Branding { inline constexpr std::wstring_view Publisher{ L"helloThisWorld" }; - inline constexpr std::wstring_view ApplicationVersion{ L"1.3.2" }; + inline constexpr std::wstring_view ApplicationVersion{ L"1.3.3" }; inline constexpr std::wstring_view ReleaseChannel{ L"Stable" }; inline constexpr std::wstring_view CommitSha{ WINTERM_BUILD_COMMIT_SHA }; inline constexpr std::wstring_view BuildTimestamp{ WINTERM_BUILD_TIMESTAMP }; diff --git a/src/winterm/Branding/version.json b/src/winterm/Branding/version.json index 6e9e0c093..54ea67914 100644 --- a/src/winterm/Branding/version.json +++ b/src/winterm/Branding/version.json @@ -1,10 +1,10 @@ { - "applicationVersion": "1.3.2", - "packageVersion": "1.3.2.0", - "moduleVersion": "1.3.2", + "applicationVersion": "1.3.3", + "packageVersion": "1.3.3.0", + "moduleVersion": "1.3.3", "modulePrerelease": "", "channel": "stable", - "tag": "v1.3.2", + "tag": "v1.3.3", "workspaceSchemaVersion": 2, "dockingModelVersion": 1, "shellProtocolVersion": 1, diff --git a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h index c19e88ebf..f88455b65 100644 --- a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h +++ b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h @@ -219,7 +219,7 @@ namespace winTerm::Workspaces std::string createdAt; std::string updatedAt; WorkspaceSource source{ WorkspaceSource::User }; - std::string applicationVersion{ "1.3.2" }; + std::string applicationVersion{ "1.3.3" }; uint32_t protocolVersion{ 1 }; uint32_t dockingModelVersion{ DockingModelVersion }; WorkspaceStartupBehavior startupBehavior; diff --git a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp index 8ec81c601..94ecf99c4 100644 --- a/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp +++ b/src/winterm/Workspaces/Persistence/WorkspaceSerializer.cpp @@ -618,7 +618,7 @@ WorkspaceDescriptor WorkspaceSerializer::FromJson(const Json::Value& json, const throw std::runtime_error("The workspace source is not supported."); } workspace.source = *source; - workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.3.2"); + workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.3.3"); workspace.protocolVersion = UIntOrDefault(json, "protocolVersion", 1); workspace.dockingModelVersion = UIntOrDefault(json, "dockingModelVersion", DockingModelVersion); if (const auto& startup = json["startupBehavior"]; !startup.isNull())