From e3033cced4f68ae6e50962b48c91eb432d3d0e17 Mon Sep 17 00:00:00 2001 From: HelloThisWorld Date: Sun, 2 Aug 2026 16:56:08 +0800 Subject: [PATCH] feat: add Command Timeline phase 2 checkpoint --- .github/PULL_REQUEST_TEMPLATE.md | 3 + AGENTS.md | 10 + CHANGELOG.md | 74 +++ CONTRIBUTING.md | 3 + README.md | 2 +- docs/current-progress.md | 77 +-- docs/development/changelog-policy.md | 26 ++ scripts/winterm/package-shell-assets.ps1 | 4 +- scripts/winterm/test-command-timeline.ps1 | 166 +++++++ scripts/winterm/test-visual-progress.ps1 | 40 +- scripts/winterm/test.ps1 | 8 +- scripts/winterm/verify-branding.ps1 | 2 +- scripts/winterm/verify-version.ps1 | 18 +- .../winTerm.Shell/winTerm.Shell.psd1 | 2 +- .../winTerm.Shell/winTerm.Shell.psm1 | 2 +- shell/shared/version.json | 4 +- .../Package-winTerm.appxmanifest | 2 +- .../TerminalApp/AppActionHandlers.cpp | 15 + src/cascadia/TerminalControl/ControlCore.cpp | 78 ++++ src/cascadia/TerminalControl/ControlCore.h | 9 + src/cascadia/TerminalControl/ControlCore.idl | 1 + .../Resources/en-US/Resources.resw | 44 +- src/cascadia/TerminalControl/TermControl.cpp | 440 ++++++++++++++++++ src/cascadia/TerminalControl/TermControl.h | 23 + src/cascadia/TerminalControl/TermControl.idl | 8 +- src/cascadia/TerminalControl/TermControl.xaml | 66 ++- .../TerminalSettingsModel/ActionAndArgs.cpp | 1 + .../TerminalSettingsModel/ActionMap.cpp | 1 + .../AllShortcutActions.h | 1 + .../Resources/en-US/Resources.resw | 9 +- .../TerminalSettingsModel/defaults.json | 10 +- .../CommandTimelineTests.cpp | 282 +++++++++++ .../KeyBindingsTests.cpp | 32 ++ .../WindowsTerminal/WindowsTerminal.rc | 8 +- src/cascadia/wt/wt.rc | 8 +- .../winterm-shim/winterm-shim.rc | 8 +- src/winterm/Branding/ReleaseMetadata.h | 2 +- src/winterm/Branding/version.json | 8 +- .../CommandTimeline/CommandTimelineModel.cpp | 361 ++++++++++++++ .../CommandTimeline/CommandTimelineModel.h | 87 ++++ .../Workspaces/Model/WorkspaceDescriptor.h | 2 +- .../Persistence/WorkspaceSerializer.cpp | 2 +- 42 files changed, 1844 insertions(+), 105 deletions(-) create mode 100644 docs/development/changelog-policy.md create mode 100644 scripts/winterm/test-command-timeline.ps1 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e81f9596f..bde96b89a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,6 +16,9 @@ List exact commands, environments, results, and any tests that were not availabl - [ ] Tests were added or updated where appropriate. - [ ] All tests claimed above actually ran and passed. - [ ] User-facing behavior and limitations are documented in this repository. +- [ ] This source/docs commit updates the root `CHANGELOG.md`. +- [ ] The Wiki `Development-Changes.md` ledger has been pushed with the final source SHA, link, summary, and checkpoint/release. +- [ ] Documentation screenshots reuse a suitable existing sanitized `winterm-site` asset, or no screenshot was added. - [ ] Version or schema changes include compatibility and migration notes. - [ ] Package identity, `winterm.exe`, and Microsoft Terminal coexistence remain isolated. - [ ] No command text, terminal output, clipboard content, credentials, or private paths are logged. diff --git a/AGENTS.md b/AGENTS.md index f47974d77..80b9643c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,16 @@ When the user says `create pr`, follow this workflow: - Remember that `workflow_dispatch` cannot dispatch a workflow that exists only outside the default branch. Use the pull-request trigger for new workflows or merge them before dispatching. - Before release operations, check for an existing tag or Release and never overwrite an existing release asset. +## Changelog and Wiki Protocol + +- Every winTerm source or documentation commit created by a developer or agent must update the root `CHANGELOG.md` in that same source commit. +- Immediately after creating a source commit, add and push a matching entry in the separate `HelloThisWorld/winTerm.wiki` `Development-Changes.md` ledger. A source commit is not complete until that Wiki commit is published. +- Record the date, final source SHA and link, concise summary, and related checkpoint or release. Update the Wiki if amend, rebase, squash, or merge changes the SHA, including GitHub-generated merge or squash commits. +- Wiki-only synchronization commits do not recursively require another Wiki ledger entry. If the Wiki push fails, report it and do not claim the source commit workflow is complete. +- Do not add a secret-bearing public CI job for Wiki writes or make changelog validation trigger an expensive native build on every pull request. +- For documentation screenshots, first reuse a suitable sanitized original from `HelloThisWorld/winterm-site`. Do not recapture, regenerate, duplicate, crop, round, mask, or frame an equivalent screenshot; use text when no suitable existing image is available. +- Follow `docs/development/changelog-policy.md` for the complete permanent policy. + ## Code Rules - Use English for code, function names, variable names, comments, logs, and error messages. diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fefde7b..294a4f82e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,79 @@ # Changelog +## 1.2.2 - 2026-08-02 + +### Added + +- Added the pane-owned Command Timeline Phase 2 overlay with a compact + left-side handle. The overlay is layered above the terminal surface and does + not change pane dimensions, terminal rows or columns, swap-chain size, PTY + size, or padding. +- Added a deterministic pure C++ navigation/presentation model that consumes + the Phase 1 index, materializes only visible entries, restores stable command + selection and visual slots, reconciles clear/eviction/reflow, and preserves + older-history browsing when new commands arrive. +- Added keyboard-only Up, Down, Left, Right, and Escape navigation, hover and + click selection, page-edge one-in/one-out behavior, and pane-local + high-precision wheel/trackpad accumulation with complete-row settling. +- Added list/list-item selection semantics, localized accessible names and + trustworthy status text, status glyphs that do not rely on color, themed + High Contrast presentation, and a no-animation Reduced Motion-safe path. +- Added focused model, control source-boundary, settings, shortcut collision, + user-override, pane isolation, cleanup, warm-access, and privacy coverage. + +### Changed + +- Remapped the canonical defaults to `Ctrl+Tab` for the focused pane's Command + Timeline, `Ctrl+T` for the next tab, `Ctrl+Shift+T` for the previous tab, and + `Ctrl+Alt+T` for a new tab. Explicit user key bindings retain precedence. +- Advanced engineering application and PowerShell module versions to `1.2.2`, + package/file versions to `1.2.2.0`, and the intended checkpoint tag to + `v1.2.2`; workspace, docking, shell, theme, update-manifest, package identity, + and signing-policy versions remain unchanged. +- Established the canonical root changelog plus GitHub Wiki changelog and + per-source-commit development ledger, together with permanent contributor, + agent, and pull-request policy for keeping them synchronized. + +### Fixed + +- Replaced the README's stale `winterm-full-build.yml` Windows build badge with + a Windows CI badge and link for the existing `winterm-validation.yml` + workflow. + +### Checkpoint status + +- `v1.2.2` is an engineering checkpoint for Command Timeline Phase 2, not a + public GitHub Release. GitHub Latest and README public downloads remain on + v1.2.0. Command insertion, copy, paste, execution, output jumping, and search + remain reserved for later phases. + +## 1.2.1 - 2026-08-02 + +### Added + +- Added the pane-owned Command Timeline Phase 1 data layer with stable + pane-scoped command IDs, native mark identity, an incremental OSC 133 + lifecycle, trustworthy completion mapping, bounded command-text caching, and + no command-output cache or persistence. +- Added cold bootstrap through native mark extents and warm access keyed by + `markRevision`, so unchanged reads do not rescan the TextBuffer. +- Added clear, scrollback-eviction, reflow, and pane-close cleanup that retains + surviving native identities without duplicate entries or cross-pane state. +- Added focused deterministic and native OSC component tests for lifecycle, + identity, capability, privacy, reflow, pruning, warm access, and cleanup. + +### Changed + +- Added bounded TAEF process-tree cleanup for hanging compiled tests and + label-gated CI classification so ordinary pull requests run quick validation + without an expensive native build; `build`, `delivery`, and `ci:full` remain + explicit maintainer-selected gates. + +### Checkpoint status + +- `v1.2.1` is an engineering checkpoint, not a public GitHub Release. It added + no Timeline overlay, input behavior, installer, Latest, or website change. + ## 1.2.0 - 2026-08-01 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 003695215..5ecdf0af9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,9 @@ Do not claim a build, package, architecture, signature, installer, or runtime te - Avoid large formatting-only changes, generated output, absolute paths, secrets, certificates, and unrelated changes. - Preserve Microsoft Terminal copyright, licenses, and third-party notices. - Keep commits focused and do not rewrite public history or force-push release work. +- Update the root `CHANGELOG.md` in every source or documentation commit. After the source commit exists, immediately add its final SHA, link, summary, and checkpoint/release to the Wiki `Development-Changes.md` ledger and push the Wiki commit. Amend, rebase, squash, and GitHub merge SHAs must be corrected or added in the Wiki; a source commit is not complete until the matching Wiki commit is published. +- Wiki-only synchronization commits do not require recursive Wiki entries. Report a Wiki push failure instead of claiming the workflow is complete, and do not add secret-bearing or expensive native-build CI to automate this policy. +- Reuse suitable sanitized application screenshots from `HelloThisWorld/winterm-site` before adding documentation imagery. Do not recapture or regenerate an equivalent image. See `docs/development/changelog-policy.md`. ## Pull requests diff --git a/README.md b/README.md index 012950499..ba1c24589 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # winTerm [![Validation](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml/badge.svg)](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml) -[![Windows build](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-full-build.yml/badge.svg)](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-full-build.yml) +[![Windows CI](https://img.shields.io/github/actions/workflow/status/HelloThisWorld/winTerm/winterm-validation.yml?branch=main&label=Windows%20CI)](https://github.com/HelloThisWorld/winTerm/actions/workflows/winterm-validation.yml) [![Latest release](https://img.shields.io/github/v/release/HelloThisWorld/winTerm?display_name=tag&label=release)](https://github.com/HelloThisWorld/winTerm/releases/latest) ## Download the latest winTerm release diff --git a/docs/current-progress.md b/docs/current-progress.md index 527381cb4..169176603 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -5,17 +5,18 @@ Last updated: 2026-08-02 ## Repository state - Branch: `feature/command-timeline-v1.3.0` -- Starting commit: `59020ff093ce8061d228d3aa3248f57a3b3301ef` +- Starting commit: `abb581a2a` (`main` after Command Timeline Phase 1 and the + label-gated CI/test-hang follow-up) - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` -- Engineering application and PowerShell module version: `1.2.1` -- Engineering package version: `1.2.1.0` -- Intended checkpoint tag: `v1.2.1` +- Engineering application and PowerShell module version: `1.2.2` +- Engineering package version: `1.2.2.0` +- Intended checkpoint tag: `v1.2.2` - Final Command Timeline release target: `v1.3.0` - Current public Latest: `v1.2.0` - Supported target: Windows 11 x64 -`v1.2.1` is a development checkpoint, not a distributable release. The README +`v1.2.2` is a development checkpoint, not a distributable release. The README and GitHub Latest continue to identify v1.2.0 as the public Visual Progress release. Checkpoint tags v1.2.1 through v1.2.4 run quick validation only and are explicitly excluded from full build, installer packaging, asset publication, @@ -23,36 +24,44 @@ and GitHub Release jobs. ## Implemented in the working tree -- Added the Command Timeline Phase 1 data layer with a pane-owned index, view - state, bounded command-text cache, and stable IDs composed from the pane - session ID plus a monotonically increasing sequence. -- Extended native scrollbar mark metadata with an internal-only identity. The - identity follows existing TextBuffer row copying and reflow and is never - emitted into terminal text, OSC payloads, settings, workspaces, telemetry, or - persistent storage. -- Reused the existing OSC 133 `StartPrompt`, `StartCommand`, `StartOutput`, and - `EndCurrentCommand` lifecycle. Updates are incremental and idempotent; - trustworthy completion codes alone map to success or failure. -- Added cold bootstrap through the existing native mark extents API and a - pane-scoped mark revision seam. Warm reads with an unchanged revision return - the cached index without rescanning the TextBuffer. -- Added explicit pruning for clear, circular-buffer eviction, and reflow loss. - Reflow reports surviving native identities during its existing traversal, so - resize preserves command IDs without a second full mark scan. -- Added deterministic model tests and a real native OSC component test covering - stable identity, pane isolation, lifecycle ordering, capability, pruning, - reflow, cache privacy, view-state ownership, and repeatable cleanup. -- Kept Phase 1 data-only: no pane handle, Timeline overlay, settings surface, - input behavior, installer, website, or public release change was added. +- Retained the Command Timeline Phase 1 pane-owned index, bounded command-text + cache, stable IDs, native mark identity, incremental OSC 133 lifecycle, warm + bootstrap, and clear/eviction/reflow cleanup as the only history data source. +- Added a pane-owned pure C++ navigation and presentation model. It restores a + valid selected ID, native anchor, and visual slot; defaults to the latest + command; chooses the nearest surviving ID after removal; and materializes + only the visible rows required by the pane. +- Added a TermControl left-side handle and overlay layered over the terminal + surface. It does not change terminal layout, padding, swap-chain or PTY size, + and its closed state leaves all other terminal pointer input untouched. +- Added Up/Down one-entry movement, page-edge one-in/one-out viewport movement, + Left/Right current-page edge selection, hover/click-only selection, Escape + close, and focused-pane toggle routing through the existing action system. +- Added pane-local wheel/trackpad delta accumulation and a lifetime-safe settle + timer. Partial deltas are accumulated, direction reversal cancels unfinished + motion, full thresholds move complete rows, and hide/close stop the timer and + clear UI-only entries. +- Added localized list/list-item accessibility, open/close handle names, + command-plus-status names, non-color status glyphs, High Contrast-aware theme + resources, and a Reduced Motion-safe path without a continuous animation. +- Remapped canonical defaults to `Ctrl+Tab` (Timeline), `Ctrl+T` (next tab), + `Ctrl+Shift+T` (previous tab), and `Ctrl+Alt+T` (new tab), while retaining + user-defined keybinding precedence and existing IME/AltGr ordering. +- Advanced authoritative engineering version surfaces to `1.2.2`/`1.2.2.0`, + fixed the README Windows CI badge, added v1.2.1 and v1.2.2 root changelog + entries, and established the permanent root changelog/Wiki ledger policy. +- Kept Phase 3 out of scope: entry click and hover only select. They do not + insert, paste, copy, execute, close, or jump to command output. ## Validation state -The focused x64 Debug Command Timeline target builds successfully, and all 12 -deterministic unit/component tests pass. Repository Smoke/static validation, -version consistency, checkpoint guard checks, and YAML parsing also pass. The -annotated checkpoint tag must point to this verified commit; the tag-triggered -quick workflow remains the remote confirmation gate. +Phase 2 validation requires the focused Command Timeline model/control tests, +Settings Model shortcut tests, TerminalApp action routing checks, XML/XAML/JSON +and PowerShell parsing, version/checkpoint guards, repository Smoke validation, +the smallest affected native projects, README badge verification, and GitHub +quick PR validation. Record exact results in the Draft PR and final task report; +do not treat this document as evidence for a command that did not run. -The existing public v1.2.0 Visual Progress feature and its release notes remain -the stable user-facing milestone. The Command Timeline work is not public -release documentation and does not change the `/releases/latest` route. +The annotated `v1.2.2` checkpoint tag must point to the final commit that passes +those gates. Its tag workflow must run checkpoint quick validation only and +must not create a GitHub Release or update Latest. diff --git a/docs/development/changelog-policy.md b/docs/development/changelog-policy.md new file mode 100644 index 000000000..853e5b0a2 --- /dev/null +++ b/docs/development/changelog-policy.md @@ -0,0 +1,26 @@ +# Changelog and documentation asset policy + +The root `CHANGELOG.md` is the canonical version history for winTerm. Every +source or documentation commit created by a developer or automation agent must +update `CHANGELOG.md` in that same source commit. A source commit is not +complete until a matching commit has also been pushed to the separate +`HelloThisWorld/winTerm.wiki` repository. + +Each `Development-Changes.md` Wiki entry must record the date, final source +commit SHA and link, a concise summary, and its related checkpoint or release. +If amend, rebase, squash, or another operation changes the source SHA, update +the Wiki entry to the final SHA. GitHub-generated merge or squash commits must +also be added once their final SHA exists. Commits that only synchronize the +Wiki do not recursively require another Wiki ledger entry. + +If the Wiki cannot be pushed, report that explicitly. Do not describe the +source commit workflow as complete until the corresponding Wiki commit is +published. Public CI must not use a secret-bearing job to write to the Wiki, +and changelog validation must not cause every pull request to run an expensive +native build. + +When application documentation benefits from a screenshot, inspect the +existing sanitized assets in `HelloThisWorld/winterm-site` first and reuse an +appropriate original image. Do not recapture, regenerate, duplicate, crop, +round, mask, or place an equivalent screenshot in a simulated browser frame. +If no suitable existing asset is available, keep the documentation text-only. diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 index 3757e00d2..19468a6d3 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.2.1' -or +if ($version.moduleVersion -ne '1.2.2' -or $version.modulePrerelease -ne '' -or - $version.applicationVersion -ne '1.2.1' -or + $version.applicationVersion -ne '1.2.2' -or $version.protocolVersion -ne 1) { throw 'The winTerm Shell asset version metadata is invalid.' diff --git a/scripts/winterm/test-command-timeline.ps1 b/scripts/winterm/test-command-timeline.ps1 new file mode 100644 index 000000000..81f1de0c9 --- /dev/null +++ b/scripts/winterm/test-command-timeline.ps1 @@ -0,0 +1,166 @@ +# Copyright (c) winTerm contributors. +# Licensed under the MIT license. + +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +function Read-Source { + param([Parameter(Mandatory)][string]$Path) + + return Get-Content -LiteralPath (Join-Path $repositoryRoot $Path) -Raw +} + +function Assert-Contains { + param( + [Parameter(Mandatory)][string]$Content, + [Parameter(Mandatory)][string[]]$Values, + [Parameter(Mandatory)][string]$Failure + ) + + foreach ($value in $Values) { + if (-not $Content.Contains($value)) { + throw "$Failure Missing '$value'." + } + } +} + +$modelHeader = Read-Source 'src\winterm\CommandTimeline\CommandTimelineModel.h' +$modelSource = Read-Source 'src\winterm\CommandTimeline\CommandTimelineModel.cpp' +$controlCoreHeader = Read-Source 'src\cascadia\TerminalControl\ControlCore.h' +$controlCoreSource = Read-Source 'src\cascadia\TerminalControl\ControlCore.cpp' +$termControlHeader = Read-Source 'src\cascadia\TerminalControl\TermControl.h' +$termControlSource = Read-Source 'src\cascadia\TerminalControl\TermControl.cpp' +$termControlXaml = Read-Source 'src\cascadia\TerminalControl\TermControl.xaml' +$defaults = Read-Source 'src\cascadia\TerminalSettingsModel\defaults.json' +$actions = Read-Source 'src\cascadia\TerminalSettingsModel\AllShortcutActions.h' +$actionSerialization = Read-Source 'src\cascadia\TerminalSettingsModel\ActionAndArgs.cpp' +$actionHandler = Read-Source 'src\cascadia\TerminalApp\AppActionHandlers.cpp' +$controlTests = Read-Source 'src\cascadia\UnitTests_Control\CommandTimelineTests.cpp' +$settingsTests = Read-Source 'src\cascadia\UnitTests_SettingsModel\KeyBindingsTests.cpp' +$resources = Read-Source 'src\cascadia\TerminalControl\Resources\en-US\Resources.resw' + +Assert-Contains -Content $modelHeader -Values @( + 'class CommandTimelineNavigationModel final', + 'CommandTimelinePresentationSnapshot', + 'CommandTimelineVisibleEntry', + 'ApplyWheelDelta', + 'SettleWheel' +) -Failure 'The pure Command Timeline navigation/presentation model is incomplete.' +Assert-Contains -Content $modelSource -Values @( + '_wheelDeltaRemainder', + 'NavigationAction::PageFirst', + 'NavigationAction::PageLast', + '_findNearestCommand', + '_effectiveResult' +) -Failure 'Command Timeline navigation semantics are incomplete.' +if ($modelHeader.Contains('commandOutput') -or $modelHeader.Contains('cachedOutput')) { + throw 'The visible Command Timeline model must never contain command output.' +} + +Assert-Contains -Content $controlCoreHeader -Values @( + 'CommandTimelineNavigationModel _commandTimelineNavigation', + 'OpenCommandTimeline', + 'RefreshCommandTimeline', + 'ScrollCommandTimeline', + 'CloseCommandTimelineOverlay' +) -Failure 'ControlCore does not own the pane-local Timeline navigation state.' +Assert-Contains -Content $controlCoreSource -Values @( + '_commandTimelineIndex->Entries()', + '_commandTimelineNavigation.Reconcile', + 'CommandTimelineChanged.raise', + '_commandTimelineNavigation.Close()' +) -Failure 'ControlCore does not project Phase 1 data incrementally or clean it up.' + +$overlayStart = $termControlXaml.IndexOf('> _updateCommandTimeline', + 'CommandTimelineChanged_revoker', + '_commandTimelineConsumedKeys' +) -Failure 'Timeline timer, event, or key cleanup ownership is incomplete.' +Assert-Contains -Content $termControlSource -Values @( + 'GetTSFHandle().HasActiveComposition()', + '_tryHandleCommandTimelineKey(vkey, modifiers, keyDown)', + '_tryHandleCommandTimelineWheel(point.Position(), delta)', + 'TextTrimming::CharacterEllipsis', + '_commandTimelineWheelSettleTimer.Stop()', + 'CommandTimelineList().Items().Clear()', + 'Focus(FocusState::Programmatic)' +) -Failure 'Timeline input isolation, IME precedence, snapping, or close cleanup is incomplete.' +if ($termControlSource.IndexOf('_TryHandleKeyBinding(vkey, scanCode, modifiers)', [StringComparison]::Ordinal) -gt + $termControlSource.IndexOf('_tryHandleCommandTimelineKey(vkey, modifiers, keyDown)', [StringComparison]::Ordinal)) { + throw 'User-defined key bindings must retain precedence over bare Timeline navigation.' +} + +Assert-Contains -Content $actions -Values @('ON_ALL_ACTIONS(ToggleCommandTimeline)') -Failure 'The Timeline shortcut action is missing.' +Assert-Contains -Content $actionSerialization -Values @('ToggleCommandTimelineKey{ "toggleCommandTimeline" }') -Failure 'The Timeline shortcut is not serializable.' +Assert-Contains -Content $actionHandler -Values @( + '_HandleToggleCommandTimeline', + 'GetActiveTerminalControl()', + 'control.ToggleCommandTimeline()' +) -Failure 'The Timeline action is not routed to the focused pane.' + +$requiredBindings = [ordered]@{ + 'ctrl+tab' = 'winTerm.ToggleCommandTimeline' + 'ctrl+t' = 'Terminal.NextTab' + 'ctrl+shift+t' = 'Terminal.PrevTab' + 'ctrl+alt+t' = 'Terminal.OpenNewTab' +} +foreach ($binding in $requiredBindings.GetEnumerator()) { + $expected = '{ "keys": "' + $binding.Key + '", "id": "' + $binding.Value + '" }' + if (-not $defaults.Contains($expected)) { + throw "Required default shortcut is missing: $($binding.Key) -> $($binding.Value)." + } + $count = ([regex]::Matches($defaults, '"keys"\s*:\s*"' + [regex]::Escape($binding.Key) + '"', 'IgnoreCase')).Count + if ($count -ne 1) { + throw "Default shortcut '$($binding.Key)' must occur exactly once, found $count." + } +} +if ($defaults.Contains('{ "keys": "ctrl+shift+tab"')) { + throw 'The replaced Ctrl+Shift+Tab default binding is still present.' +} + +Assert-Contains -Content $resources -Values @( + 'CommandTimelineHandle.[using:Windows.UI.Xaml.Automation]AutomationProperties.Name', + 'Open command timeline', + 'CommandTimelineStatusUnknown' +) -Failure 'Timeline accessibility names or status text are missing.' + +Assert-Contains -Content $controlTests -Values @( + 'NavigationPageEdgesMoveOneInOneOut', + 'NavigationWheelAccumulatesReversesAndSettles', + 'NavigationStateIsPaneLocalAndRepeatedCloseIsSafe', + 'ColdBootstrapScansOnceAndWarmAccessDoesNotRescan' +) -Failure 'Deterministic Timeline navigation, pane isolation, cleanup, or warm-access tests are missing.' +Assert-Contains -Content $settingsTests -Values @( + 'CommandTimelineDefaultShortcutsAndUserOverride', + 'ShortcutAction::ToggleCommandTimeline', + 'OriginTag::User' +) -Failure 'Settings Model shortcut and user-precedence tests are missing.' + +[xml](Read-Source 'src\cascadia\TerminalControl\TermControl.xaml') | Out-Null +[xml](Read-Source 'src\cascadia\TerminalControl\Resources\en-US\Resources.resw') | Out-Null +Write-Host 'PASS: Command Timeline Phase 2 source, input, accessibility, privacy, and lifecycle boundaries' -ForegroundColor Green diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index 173027eed..a7e0f51ab 100644 --- a/scripts/winterm/test-visual-progress.ps1 +++ b/scripts/winterm/test-visual-progress.ps1 @@ -1378,12 +1378,12 @@ try $version = $source.VersionMetadata | ConvertFrom-Json $expectedVersionValues = [ordered]@{ - applicationVersion = '1.2.1' - packageVersion = '1.2.1.0' - moduleVersion = '1.2.1' + applicationVersion = '1.2.2' + packageVersion = '1.2.2.0' + moduleVersion = '1.2.2' modulePrerelease = '' channel = 'stable' - tag = 'v1.2.1' + tag = 'v1.2.2' workspaceSchemaVersion = 2 dockingModelVersion = 1 shellProtocolVersion = 1 @@ -1398,39 +1398,39 @@ try } } $shellVersion = $source.ShellVersion | ConvertFrom-Json - if ($shellVersion.applicationVersion -ne '1.2.1' -or $shellVersion.moduleVersion -ne '1.2.1' -or $shellVersion.protocolVersion -ne 1) + if ($shellVersion.applicationVersion -ne '1.2.2' -or $shellVersion.moduleVersion -ne '1.2.2' -or $shellVersion.protocolVersion -ne 1) { - throw 'Shell version metadata does not match winTerm engineering checkpoint 1.2.1 with protocol version 1.' + throw 'Shell version metadata does not match winTerm engineering checkpoint 1.2.2 with protocol version 1.' } foreach ($surface in @( - @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.2.1" }'; Description = 'About release metadata' }, - @{ Content = $source.PackageManifest; Value = 'Version="1.2.1.0"'; Description = 'MSIX package manifest' }, - @{ Content = $source.HostResource; Value = 'FILEVERSION 1,2,1,0'; Description = 'Terminal host file version' }, - @{ Content = $source.HostResource; Value = '"ProductVersion", "1.2.1\0"'; Description = 'Terminal host display version' }, - @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,2,1,0'; Description = 'Shim file version' }, - @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.2.1\0"'; Description = 'Shim display version' }, + @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.2.2" }'; Description = 'About release metadata' }, + @{ Content = $source.PackageManifest; Value = 'Version="1.2.2.0"'; Description = 'MSIX package manifest' }, + @{ Content = $source.HostResource; Value = 'FILEVERSION 1,2,2,0'; Description = 'Terminal host file version' }, + @{ Content = $source.HostResource; Value = '"ProductVersion", "1.2.2\0"'; Description = 'Terminal host display version' }, + @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,2,2,0'; Description = 'Shim file version' }, + @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.2.2\0"'; Description = 'Shim display version' }, @{ Content = $source.CustomProps; Value = '1'; Description = 'Executable major version' }, @{ Content = $source.CustomProps; Value = '2'; Description = 'Executable minor version' }, - @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.2.1'"; Description = 'PowerShell module manifest' }, - @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.2.1'"; Description = 'PowerShell module runtime' }, + @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.2.2'"; Description = 'PowerShell module manifest' }, + @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.2.2'"; Description = 'PowerShell module runtime' }, @{ Content = $source.PackageShellAssets; Value = "'shell\shared\version.json'"; Description = 'Canonical shell version metadata packaging' }, - @{ Content = $source.WorkspaceSerializer; Value = '"1.2.1"'; Description = 'Workspace application-version fallback' } + @{ Content = $source.WorkspaceSerializer; Value = '"1.2.2"'; Description = 'Workspace application-version fallback' } )) { Assert-Contains $surface.Content $surface.Value $surface.Description } foreach ($required in @( - "applicationVersion -eq '1.2.1'", - "packageVersion -eq '1.2.1.0'", - "moduleVersion -eq '1.2.1'", - "tag -eq 'v1.2.1'", + "applicationVersion -eq '1.2.2'", + "packageVersion -eq '1.2.2.0'", + "moduleVersion -eq '1.2.2'", + "tag -eq 'v1.2.2'", "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.2.1 version validation surface' + Assert-Contains $source.VerifyVersion $required 'Authoritative v1.2.2 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 baaefe768..0a452cbc9 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.2.1' -or + if ($manifest.ModuleVersion -ne '1.2.2' -or $manifest.PrivateData.PSData.Prerelease -ne '' -or $manifest.PowerShellVersion -ne '5.1') { @@ -457,6 +457,12 @@ try throw 'Visual Progress source validation failed.' } + & (Join-Path $PSScriptRoot 'test-command-timeline.ps1') + if (-not $?) + { + throw 'Command Timeline Phase 2 source validation failed.' + } + & (Join-Path $PSScriptRoot 'test-diagnostics.ps1') if (-not $?) { diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 3ea680563..b75113868 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.2.1.0') -Message "$Path uses package version 1.2.1.0" + Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.2.2.0') -Message "$Path uses package version 1.2.2.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 6e69e43f2..db3bba2d5 100644 --- a/scripts/winterm/verify-version.ps1 +++ b/scripts/winterm/verify-version.ps1 @@ -49,12 +49,12 @@ try $versionPath = Join-Path $repositoryRoot 'src\winterm\Branding\version.json' $version = Get-Content -LiteralPath $versionPath -Raw | ConvertFrom-Json - Assert-Condition ($version.applicationVersion -eq '1.2.1') 'Application version is 1.2.1' - Assert-Condition ($version.packageVersion -eq '1.2.1.0') 'Package version is 1.2.1.0' - Assert-Condition ($version.moduleVersion -eq '1.2.1') 'PowerShell module version is 1.2.1' + Assert-Condition ($version.applicationVersion -eq '1.2.2') 'Application version is 1.2.2' + Assert-Condition ($version.packageVersion -eq '1.2.2.0') 'Package version is 1.2.2.0' + Assert-Condition ($version.moduleVersion -eq '1.2.2') 'PowerShell module version is 1.2.2' Assert-Condition ($version.modulePrerelease -eq '') 'PowerShell module has no prerelease suffix' Assert-Condition ($version.channel -eq 'stable') 'Release channel is stable' - Assert-Condition ($version.tag -eq 'v1.2.1') 'Engineering checkpoint tag is v1.2.1' + Assert-Condition ($version.tag -eq 'v1.2.2') 'Engineering checkpoint tag is v1.2.2' 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' @@ -78,7 +78,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.2.1'")) 'PowerShell module runtime version matches release metadata' + Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.2.2'")) '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' @@ -86,7 +86,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.2.1" }')) 'About metadata application version is 1.2.1' + Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.2.2" }')) 'About metadata application version is 1.2.2' 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' @@ -117,10 +117,10 @@ 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.2.1" }')) 'Workspace model application-version fallback is 1.2.1' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('applicationVersion{ "1.2.2" }')) 'Workspace model application-version fallback is 1.2.2' 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.2.1"')) 'Workspace serializer application-version fallback is 1.2.1' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.2.2"')) 'Workspace serializer application-version fallback is 1.2.2' $releaseWorkflow = Get-Text '.github\workflows\release.yml' Assert-Condition ($releaseWorkflow.Contains("- 'v*'")) 'Release workflow accepts version tags through a generic guarded trigger' @@ -135,7 +135,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.2.1' + Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.2.2' } 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 9f1826a7d..ee8b86373 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.2.1' + ModuleVersion = '1.2.2' 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 4e3fd5033..1eeac02d3 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.2.1' +$script:WinTermModuleVersion = '1.2.2' $script:WinTermProtocolVersion = 1 $script:WinTermIntegrationEnabled = $false $script:WinTermPromptWrapped = $false diff --git a/shell/shared/version.json b/shell/shared/version.json index 11d0c0928..3795291dd 100644 --- a/shell/shared/version.json +++ b/shell/shared/version.json @@ -1,6 +1,6 @@ { - "applicationVersion": "1.2.1", - "moduleVersion": "1.2.1", + "applicationVersion": "1.2.2", + "moduleVersion": "1.2.2", "modulePrerelease": "", "protocolVersion": 1 } diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index 92712f084..c2f16e6e2 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="1.2.2.0" /> winTerm diff --git a/src/cascadia/TerminalApp/AppActionHandlers.cpp b/src/cascadia/TerminalApp/AppActionHandlers.cpp index 6caf6e78b..a7d6951df 100644 --- a/src/cascadia/TerminalApp/AppActionHandlers.cpp +++ b/src/cascadia/TerminalApp/AppActionHandlers.cpp @@ -351,6 +351,21 @@ namespace winrt::TerminalApp::implementation args.Handled(true); } + void TerminalPage::_HandleToggleCommandTimeline(const IInspectable& sender, + const ActionEventArgs& args) + { + if (const auto activeTab{ _senderOrFocusedTab(sender) }) + { + if (const auto control{ activeTab->GetActiveTerminalControl() }) + { + args.Handled(control.ToggleCommandTimeline()); + return; + } + } + + args.Handled(false); + } + void TerminalPage::_HandleTogglePaneReadOnly(const IInspectable& sender, const ActionEventArgs& args) { diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 877de5fcf..fc32cea4d 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -1666,6 +1666,72 @@ namespace winrt::Microsoft::Terminal::Control::implementation } } + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::OpenCommandTimeline(const size_t visibleCapacity) + { + const auto lock = _terminal->LockForWriting(); + _ensureCommandTimelineBootstrap(); + return _commandTimelineNavigation.Open( + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability(), + visibleCapacity); + } + + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::RefreshCommandTimeline(const size_t visibleCapacity) + { + const auto lock = _terminal->LockForWriting(); + _ensureCommandTimelineBootstrap(); + return _commandTimelineNavigation.Reconcile( + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability(), + visibleCapacity); + } + + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::NavigateCommandTimeline( + const winTerm::CommandTimeline::NavigationAction action) + { + const auto lock = _terminal->LockForWriting(); + return _commandTimelineNavigation.Navigate( + action, + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability()); + } + + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::SelectCommandTimelineVisibleEntry( + const size_t visualSlot) + { + const auto lock = _terminal->LockForWriting(); + return _commandTimelineNavigation.SelectVisibleEntry( + visualSlot, + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability()); + } + + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::ScrollCommandTimeline(const int wheelDelta) + { + const auto lock = _terminal->LockForWriting(); + return _commandTimelineNavigation.ApplyWheelDelta( + wheelDelta, + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability()); + } + + void ControlCore::SettleCommandTimelineWheel() + { + const auto lock = _terminal->LockForWriting(); + _commandTimelineNavigation.SettleWheel(); + } + + void ControlCore::CloseCommandTimelineOverlay() + { + const auto lock = _terminal->LockForWriting(); + _commandTimelineNavigation.Close(); + } + int ControlCore::ScrollOffset() { const auto lock = _terminal->LockForReading(); @@ -1766,6 +1832,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation { _terminalCommandTimelineBufferChanged(); _ensureCommandTimelineBootstrap(); + const auto timelineRevisionBefore = _commandTimelineIndex->Revision(); const auto nativeMarkId = _terminal->GetCurrentCommandTimelineMarkIdentity(); const auto shellIntegrationState = static_cast(_terminal->GetShellIntegrationState()); @@ -1806,6 +1873,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation }); } + if (_commandTimelineIndex->Revision() != timelineRevisionBefore) + { + CommandTimelineChanged.raise(*this, nullptr); + } + // OSC 133 prompt is the per-pane ownership boundary. Defer parser // cleanup to the next output callback so this terminal callback never // waits on the recognition mutex while the terminal lock is held. @@ -1829,12 +1901,17 @@ namespace winrt::Microsoft::Terminal::Control::implementation return; } + const auto timelineRevisionBefore = _commandTimelineIndex->Revision(); const auto nativeRevision = _terminal->GetCommandTimelineMarkRevision(); _commandTimelineIndex->InvalidateNativeMarks(invalidated, nativeRevision); if (reflow.has_value()) { _commandTimelineIndex->ReconcileReflow(*reflow, nativeRevision); } + if (_commandTimelineIndex->Revision() != timelineRevisionBefore) + { + CommandTimelineChanged.raise(*this, nullptr); + } } void ControlCore::_ensureCommandTimelineBootstrap() @@ -2047,6 +2124,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation { _commandTimelineIndex->Close(); } + _commandTimelineNavigation.Close(); _commandTimelineViewState.Reset(); } diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index 06c13f640..ed3aeb4e3 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -176,6 +176,13 @@ namespace winrt::Microsoft::Terminal::Control::implementation const uint64_t VisualProgressProviderState() const noexcept; winTerm::CommandTimeline::CommandTimelineSnapshot CommandTimelineSnapshot(); void CommandTimelineViewState(const winTerm::CommandTimeline::CommandTimelineViewState& state); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot OpenCommandTimeline(size_t visibleCapacity); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot RefreshCommandTimeline(size_t visibleCapacity); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot NavigateCommandTimeline(winTerm::CommandTimeline::NavigationAction action); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot SelectCommandTimelineVisibleEntry(size_t visualSlot); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ScrollCommandTimeline(int wheelDelta); + void SettleCommandTimelineWheel(); + void CloseCommandTimelineOverlay(); hstring Title(); Windows::Foundation::IReference TabColor() noexcept; @@ -296,6 +303,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation til::typed_event ScrollPositionChanged; til::typed_event<> TaskbarProgressChanged; til::typed_event<> ShellIntegrationChanged; + til::typed_event<> CommandTimelineChanged; til::typed_event<> VisualProgressProviderChanged; til::typed_event<> ConnectionStateChanged; til::typed_event<> HoveredHyperlinkChanged; @@ -448,6 +456,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation std::mutex _visualProgressSuppressionMutex; std::unique_ptr _visualProgressRecognition; std::unique_ptr _commandTimelineIndex; + winTerm::CommandTimeline::CommandTimelineNavigationModel _commandTimelineNavigation; winTerm::CommandTimeline::CommandTimelineViewState _commandTimelineViewState; std::atomic _visualProgressProviderState{}; std::atomic _visualProgressProviderGeneration{}; diff --git a/src/cascadia/TerminalControl/ControlCore.idl b/src/cascadia/TerminalControl/ControlCore.idl index 78f43dabe..524ebb810 100644 --- a/src/cascadia/TerminalControl/ControlCore.idl +++ b/src/cascadia/TerminalControl/ControlCore.idl @@ -197,6 +197,7 @@ namespace Microsoft.Terminal.Control event Windows.Foundation.TypedEventHandler BackgroundColorChanged; event Windows.Foundation.TypedEventHandler TaskbarProgressChanged; event Windows.Foundation.TypedEventHandler ShellIntegrationChanged; + event Windows.Foundation.TypedEventHandler CommandTimelineChanged; event Windows.Foundation.TypedEventHandler VisualProgressProviderChanged; event Windows.Foundation.TypedEventHandler RendererEnteredErrorState; event Windows.Foundation.TypedEventHandler ShowWindowChanged; diff --git a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw index 9d4d0c978..1915d0b98 100644 --- a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw @@ -334,4 +334,46 @@ over 999 Will be read out by a screen reader when a search returns over 999 search results. - \ No newline at end of file + + Command timeline + + + Command timeline + + + Open command timeline + + + Open command timeline + + + Open command timeline + + + Close command timeline + + + No commands yet + + + Command timeline unavailable + + + Command text unavailable + + + Running + + + Succeeded + + + Failed + + + Cancelled + + + Unknown + + diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 8d54d65fa..c03eaa748 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -30,6 +30,13 @@ using namespace winrt::Windows::Storage::Streams; // The updates are throttled to limit power usage. 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); + +// A short debounce marks the end of high-precision wheel/trackpad input and +// discards any partial-row remainder without running a continuous animation. +constexpr const auto CommandTimelineWheelSettleInterval = std::chrono::milliseconds(140); + // The minimum delay between updating the TSF input control. // This is already throttled primarily in the ControlCore, with a timeout of 100ms. We're adding another smaller one here, as the (potentially x-proc) call will come in off the UI thread constexpr const auto TsfRedrawInterval = std::chrono::milliseconds(8); @@ -386,6 +393,20 @@ namespace winrt::Microsoft::Terminal::Control::implementation } }); + _updateCommandTimeline = std::make_shared>( + dispatcher, + til::throttled_func_options{ + .delay = CommandTimelineUpdateInterval, + .trailing = true, + }, + [weakThis = get_weak()]() { + if (auto control{ weakThis.get() }; control && !control->_IsClosing() && control->_commandTimelineOpen) + { + control->_refreshCommandTimeline(); + } + }); + _revokers.CommandTimelineChanged = _core.CommandTimelineChanged(winrt::auto_revoke, { get_weak(), &TermControl::_coreCommandTimelineChanged }); + // These events might all be triggered by the connection, but that // should be drained and closed before we complete destruction. So these // are safe. @@ -400,6 +421,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation _autoScrollTimer.Interval(AutoScrollUpdateInterval); _autoScrollTimer.Tick({ get_weak(), &TermControl::_UpdateAutoScroll }); + _commandTimelineWheelSettleTimer.Interval(CommandTimelineWheelSettleInterval); + _commandTimelineWheelSettleTimer.Tick({ get_weak(), &TermControl::_CommandTimelineWheelSettled }); + _ApplyUISettings(); _originalPrimaryElements = winrt::single_threaded_observable_vector(); @@ -1807,6 +1831,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation return true; } + if (_tryHandleCommandTimelineKey(vkey, modifiers, keyDown)) + { + return true; + } + if (_TrySendKeyEvent(vkey, scanCode, modifiers, keyDown)) { return true; @@ -2146,6 +2175,12 @@ namespace winrt::Microsoft::Terminal::Control::implementation const auto point = args.GetCurrentPoint(*this); auto delta = point.Properties().MouseWheelDelta(); + if (!point.Properties().IsHorizontalMouseWheel() && + _tryHandleCommandTimelineWheel(point.Position(), delta)) + { + args.Handled(true); + return; + } auto result = _interactivity.MouseWheel(ControlKeyStates{ args.KeyModifiers() }, point.Properties().IsHorizontalMouseWheel() ? Core::Point{ delta, 0 } : @@ -2174,6 +2209,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation const bool midButtonDown, const bool rightButtonDown) { + if (delta.Y != 0 && _tryHandleCommandTimelineWheel(location, delta.Y)) + { + return true; + } + const auto modifiers = _GetPressedModifierKeys(); Control::MouseButtonState state{}; @@ -2184,6 +2224,361 @@ namespace winrt::Microsoft::Terminal::Control::implementation return _interactivity.MouseWheel(modifiers, delta, _toTerminalOrigin(location), state); } + bool TermControl::_tryHandleCommandTimelineKey(const WORD vkey, + const ControlKeyStates modifiers, + const bool keyDown) + { + if (vkey < _commandTimelineConsumedKeys.size() && !keyDown && _commandTimelineConsumedKeys[vkey]) + { + _commandTimelineConsumedKeys[vkey] = false; + return true; + } + + if (!_commandTimelineOpen || + modifiers.IsCtrlPressed() || modifiers.IsAltPressed() || + modifiers.IsShiftPressed() || modifiers.IsWinPressed()) + { + return false; + } + + std::optional action; + switch (vkey) + { + case VK_UP: + action = winTerm::CommandTimeline::NavigationAction::Previous; + break; + case VK_DOWN: + action = winTerm::CommandTimeline::NavigationAction::Next; + break; + case VK_LEFT: + action = winTerm::CommandTimeline::NavigationAction::PageFirst; + break; + case VK_RIGHT: + action = winTerm::CommandTimeline::NavigationAction::PageLast; + break; + case VK_ESCAPE: + break; + default: + return false; + } + + if (vkey < _commandTimelineConsumedKeys.size()) + { + _commandTimelineConsumedKeys[vkey] = true; + } + if (!keyDown) + { + return true; + } + + if (vkey == VK_ESCAPE) + { + _closeCommandTimeline(true); + return true; + } + + try + { + const auto presentation = get_self(_core)->NavigateCommandTimeline(*action); + _renderCommandTimeline(presentation); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + return true; + } + + bool TermControl::_tryHandleCommandTimelineWheel(const Windows::Foundation::Point& position, + const int delta) + { + if (!_isPointOverCommandTimeline(position)) + { + return false; + } + + try + { + const auto presentation = get_self(_core)->ScrollCommandTimeline(delta); + _renderCommandTimeline(presentation); + _commandTimelineWheelSettleTimer.Stop(); + _commandTimelineWheelSettleTimer.Start(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + return true; + } + + bool TermControl::_isPointOverCommandTimeline(const Windows::Foundation::Point& position) noexcept + { + if (!_commandTimelineOpen) + { + return false; + } + + const auto overlay = CommandTimelineOverlay(); + return position.X >= 0 && position.Y >= 0 && + position.X < overlay.ActualWidth() && position.Y < overlay.ActualHeight(); + } + + void TermControl::_CommandTimelineWheelHandler(const IInspectable& /*sender*/, + const PointerRoutedEventArgs& args) + { + if (_IsClosing()) + { + return; + } + + const auto point = args.GetCurrentPoint(*this); + if (!point.Properties().IsHorizontalMouseWheel() && + _tryHandleCommandTimelineWheel(point.Position(), point.Properties().MouseWheelDelta())) + { + args.Handled(true); + } + } + + void TermControl::_CommandTimelineHandleClick(const IInspectable& /*sender*/, + const RoutedEventArgs& /*args*/) + { + ToggleCommandTimeline(); + } + + void TermControl::_CommandTimelineSelectionChanged(const IInspectable& /*sender*/, + const Controls::SelectionChangedEventArgs& /*args*/) + { + if (_IsClosing() || !_commandTimelineOpen || _updatingCommandTimelineSelection) + { + return; + } + + const auto selectedIndex = CommandTimelineList().SelectedIndex(); + if (selectedIndex < 0) + { + return; + } + + try + { + const auto presentation = get_self(_core)->SelectCommandTimelineVisibleEntry( + gsl::narrow_cast(selectedIndex)); + _renderCommandTimeline(presentation); + Focus(FocusState::Programmatic); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + } + + void TermControl::_CommandTimelineSizeChanged(const IInspectable& /*sender*/, + const SizeChangedEventArgs& args) + { + if (!_commandTimelineOpen || _IsClosing()) + { + return; + } + + const auto width = std::clamp(static_cast(args.NewSize().Width) * 0.42, 180.0, 360.0); + CommandTimelineOverlay().Width(std::max(1.0, std::min(width, static_cast(args.NewSize().Width)))); + _refreshCommandTimeline(); + } + + void TermControl::_CommandTimelineWheelSettled(const IInspectable& /*sender*/, + const IInspectable& /*args*/) + { + _commandTimelineWheelSettleTimer.Stop(); + if (_commandTimelineOpen && !_IsClosing()) + { + try + { + get_self(_core)->SettleCommandTimelineWheel(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + } + } + + size_t TermControl::_commandTimelineVisibleCapacity() noexcept + { + constexpr double headerAndPaddingHeight{ 50.0 }; + constexpr double entryHeight{ 48.0 }; + const auto availableHeight = std::max(0.0, ActualHeight() - headerAndPaddingHeight); + return std::max(1, static_cast(availableHeight / entryHeight)); + } + + void TermControl::_refreshCommandTimeline() + { + if (!_commandTimelineOpen || _IsClosing()) + { + return; + } + + try + { + const auto presentation = get_self(_core)->RefreshCommandTimeline(_commandTimelineVisibleCapacity()); + _renderCommandTimeline(presentation); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + } + + void TermControl::_renderCommandTimeline(const winTerm::CommandTimeline::CommandTimelinePresentationSnapshot& presentation) + { + if (!_commandTimelineOpen || _IsClosing() || !presentation.open) + { + return; + } + + const auto list = CommandTimelineList(); + _updatingCommandTimelineSelection = true; + list.SelectedIndex(-1); + list.Items().Clear(); + + if (presentation.visibleEntries.empty()) + { + list.Visibility(Visibility::Collapsed); + CommandTimelineEmptyText().Text( + presentation.capability == winTerm::CommandTimeline::ShellIntegrationCapability::Limited ? + RS_(L"CommandTimelineUnavailable") : + RS_(L"CommandTimelineNoCommands")); + CommandTimelineEmptyText().Visibility(Visibility::Visible); + _updatingCommandTimelineSelection = false; + return; + } + + list.Visibility(Visibility::Visible); + CommandTimelineEmptyText().Visibility(Visibility::Collapsed); + + const auto statusPresentation = [](const winTerm::CommandTimeline::ExecutionResult result) { + using winTerm::CommandTimeline::ExecutionResult; + switch (result) + { + case ExecutionResult::Running: + return std::pair{ std::wstring{ L"\x25CF" }, std::wstring{ RS_(L"CommandTimelineStatusRunning") } }; + case ExecutionResult::Succeeded: + return std::pair{ std::wstring{ L"\x2713" }, std::wstring{ RS_(L"CommandTimelineStatusSucceeded") } }; + case ExecutionResult::Failed: + return std::pair{ std::wstring{ L"\x2715" }, std::wstring{ RS_(L"CommandTimelineStatusFailed") } }; + case ExecutionResult::Cancelled: + return std::pair{ std::wstring{ L"\x25A0" }, std::wstring{ RS_(L"CommandTimelineStatusCancelled") } }; + default: + return std::pair{ std::wstring{ L"?" }, std::wstring{ RS_(L"CommandTimelineStatusUnknown") } }; + } + }; + + for (size_t slot = 0; slot < presentation.visibleEntries.size(); ++slot) + { + const auto& entry = presentation.visibleEntries[slot]; + const auto [statusGlyph, statusLabel] = statusPresentation(entry.executionResult); + const auto commandText = entry.commandText.empty() ? std::wstring{ RS_(L"CommandTimelineCommandUnavailable") } : entry.commandText; + + Controls::TextBlock command; + command.Text(winrt::hstring{ commandText }); + command.TextTrimming(TextTrimming::CharacterEllipsis); + command.TextWrapping(TextWrapping::NoWrap); + command.MaxLines(1); + + Controls::TextBlock status; + status.Text(winrt::hstring{ std::wstring{ statusGlyph } + L" " + std::wstring{ statusLabel } }); + status.FontSize(11); + + Controls::StackPanel content; + content.Spacing(2); + content.Children().Append(command); + content.Children().Append(status); + + Controls::ListViewItem item; + item.Content(content); + item.MinHeight(48); + item.Padding({ 8, 5, 8, 5 }); + item.HorizontalContentAlignment(HorizontalAlignment::Stretch); + item.IsTabStop(false); + + const auto accessibleName = commandText + L", " + std::wstring{ statusLabel }; + Windows::UI::Xaml::Automation::AutomationProperties::SetName(item, winrt::hstring{ accessibleName }); + Windows::UI::Xaml::Automation::AutomationProperties::SetPositionInSet( + item, + gsl::narrow_cast(presentation.firstVisibleIndex + slot + 1)); + Windows::UI::Xaml::Automation::AutomationProperties::SetSizeOfSet( + item, + gsl::narrow_cast(presentation.totalEntryCount)); + + item.PointerEntered([weakThis = get_weak(), slot](const auto&, const auto&) { + if (auto control{ weakThis.get() }; + control && !control->_IsClosing() && control->_commandTimelineOpen && + control->CommandTimelineList().SelectedIndex() != gsl::narrow_cast(slot)) + { + try + { + const auto updated = get_self(control->_core)->SelectCommandTimelineVisibleEntry(slot); + control->_renderCommandTimeline(updated); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + control->_closeCommandTimeline(true); + } + } + }); + list.Items().Append(item); + } + + list.SelectedIndex(gsl::narrow_cast(presentation.selectedVisualSlot)); + _updatingCommandTimelineSelection = false; + } + + void TermControl::_coreCommandTimelineChanged(const IInspectable& /*sender*/, + const IInspectable& /*args*/) + { + if (_updateCommandTimeline) + { + _updateCommandTimeline->Run(); + } + } + + void TermControl::_closeCommandTimeline(const bool returnFocus) + { + _commandTimelineWheelSettleTimer.Stop(); + if (_core) + { + try + { + get_self(_core)->CloseCommandTimelineOverlay(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } + } + + _commandTimelineOpen = false; + _updatingCommandTimelineSelection = true; + CommandTimelineList().Items().Clear(); + CommandTimelineList().SelectedIndex(-1); + _updatingCommandTimelineSelection = false; + CommandTimelineEmptyText().Visibility(Visibility::Collapsed); + CommandTimelineOverlay().Visibility(Visibility::Collapsed); + Windows::UI::Xaml::Automation::AutomationProperties::SetName(CommandTimelineHandle(), RS_(L"CommandTimelineOpen")); + Controls::ToolTipService::SetToolTip(CommandTimelineHandle(), box_value(RS_(L"CommandTimelineOpen"))); + CommandTimelineHandleIcon().Glyph(L"\xE76C"); + + if (returnFocus && !_IsClosing()) + { + Focus(FocusState::Programmatic); + } + } + // Method Description: // - Called in response to the core's TransparencyChanged event. We'll use // this to update our background brush. @@ -2637,6 +3032,48 @@ namespace winrt::Microsoft::Terminal::Control::implementation _core.OpenCWD(); } + bool TermControl::ToggleCommandTimeline() + { + if (_IsClosing()) + { + return false; + } + + if (_commandTimelineOpen) + { + _closeCommandTimeline(true); + return true; + } + + try + { + _commandTimelineOpen = true; + CommandTimelineOverlay().Visibility(Visibility::Visible); + CommandTimelineHandle().Margin({ 8, 0, 0, 0 }); + Windows::UI::Xaml::Automation::AutomationProperties::SetName(CommandTimelineHandle(), RS_(L"CommandTimelineClose")); + Controls::ToolTipService::SetToolTip(CommandTimelineHandle(), box_value(RS_(L"CommandTimelineClose"))); + CommandTimelineHandleIcon().Glyph(L"\xE76B"); + + const auto width = std::clamp(ActualWidth() * 0.42, 180.0, 360.0); + CommandTimelineOverlay().Width(std::max(1.0, std::min(width, ActualWidth()))); + const auto presentation = get_self(_core)->OpenCommandTimeline(_commandTimelineVisibleCapacity()); + _renderCommandTimeline(presentation); + Focus(FocusState::Programmatic); + return true; + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + return false; + } + } + + bool TermControl::CommandTimelineOpen() const noexcept + { + return _commandTimelineOpen; + } + void TermControl::Close() { if (!_IsClosing()) @@ -2658,8 +3095,11 @@ namespace winrt::Microsoft::Terminal::Control::implementation // In turn, we leak TermControl instances. This results in constant HWND messages // while the thread is supposed to be idle. Stop these timers avoids this. _autoScrollTimer.Stop(); + _commandTimelineWheelSettleTimer.Stop(); _bellLightTimer.Stop(); + _closeCommandTimeline(false); + // This is absolutely crucial, as the TSF code tries to hold a strong reference to _tsfDataProvider, // but right now _tsfDataProvider implements IUnknown as a no-op. This ensures that TSF stops referencing us. // ~TermControl() calls Close() so this should be safe. diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index d2de4b299..b75eff1c9 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -9,6 +9,7 @@ #include "../../cascadia/TerminalCore/Terminal.hpp" #include "../../renderer/uia/UiaRenderer.hpp" #include "../../tsf/Handle.h" +#include "../../winterm/CommandTimeline/CommandTimelineModel.h" #include "ControlInteractivity.h" @@ -71,6 +72,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation void RestoreFromPath(winrt::hstring path); void PersistTo(int64_t handle) const; void OpenCWD(); + bool ToggleCommandTimeline(); + bool CommandTimelineOpen() const noexcept; void Close(); Windows::Foundation::Size CharacterDimensions() const; Windows::Foundation::Size MinimumSize(); @@ -305,6 +308,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation }; std::shared_ptr> _updateScrollBar; + std::shared_ptr> _updateCommandTimeline; bool _isInternalScrollBarUpdate; @@ -313,8 +317,12 @@ namespace winrt::Microsoft::Terminal::Control::implementation double _autoScrollVelocity; std::optional _autoScrollingPointerPoint; SafeDispatcherTimer _autoScrollTimer; + SafeDispatcherTimer _commandTimelineWheelSettleTimer; std::optional _lastAutoScrollUpdateTime; bool _pointerPressedInBounds{ false }; + bool _commandTimelineOpen{ false }; + bool _updatingCommandTimelineSelection{ false }; + std::array _commandTimelineConsumedKeys{}; winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellLightAnimation{ nullptr }; winrt::Windows::UI::Composition::ScalarKeyFrameAnimation _bellDarkAnimation{ nullptr }; @@ -378,8 +386,22 @@ namespace winrt::Microsoft::Terminal::Control::implementation void _PointerReleasedHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); void _PointerExitedHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); void _MouseWheelHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _CommandTimelineWheelHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); + void _CommandTimelineHandleClick(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::RoutedEventArgs& e); + void _CommandTimelineSelectionChanged(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Controls::SelectionChangedEventArgs& e); + void _CommandTimelineSizeChanged(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::SizeChangedEventArgs& e); + void _CommandTimelineWheelSettled(const Windows::Foundation::IInspectable& sender, const Windows::Foundation::IInspectable& e); void _ScrollbarChangeHandler(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Controls::Primitives::RangeBaseValueChangedEventArgs& e); + bool _tryHandleCommandTimelineKey(WORD vkey, ::Microsoft::Terminal::Core::ControlKeyStates modifiers, bool keyDown); + bool _tryHandleCommandTimelineWheel(const Windows::Foundation::Point& position, int delta); + bool _isPointOverCommandTimeline(const Windows::Foundation::Point& position) noexcept; + size_t _commandTimelineVisibleCapacity() noexcept; + void _refreshCommandTimeline(); + void _renderCommandTimeline(const winTerm::CommandTimeline::CommandTimelinePresentationSnapshot& presentation); + void _closeCommandTimeline(bool returnFocus); + void _coreCommandTimelineChanged(const IInspectable& sender, const IInspectable& args); + void _QuickFixButton_PointerEntered(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); void _QuickFixButton_PointerExited(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::PointerRoutedEventArgs& e); @@ -471,6 +493,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation Control::ControlCore::TabColorChanged_revoker TabColorChanged; Control::ControlCore::TaskbarProgressChanged_revoker TaskbarProgressChanged; Control::ControlCore::ShellIntegrationChanged_revoker ShellIntegrationChanged; + Control::ControlCore::CommandTimelineChanged_revoker CommandTimelineChanged; Control::ControlCore::VisualProgressProviderChanged_revoker VisualProgressProviderChanged; Control::ControlCore::ConnectionStateChanged_revoker ConnectionStateChanged; Control::ControlCore::ShowWindowChanged_revoker ShowWindowChanged; diff --git a/src/cascadia/TerminalControl/TermControl.idl b/src/cascadia/TerminalControl/TermControl.idl index ceb177b82..2dda130c6 100644 --- a/src/cascadia/TerminalControl/TermControl.idl +++ b/src/cascadia/TerminalControl/TermControl.idl @@ -109,9 +109,11 @@ namespace Microsoft.Terminal.Control Boolean ExpandSelectionToWord(); void ClearBuffer(ClearBufferType clearType); void RestoreFromPath(String path); - void PersistTo(Int64 handle); - void OpenCWD(); - void Close(); + void PersistTo(Int64 handle); + void OpenCWD(); + Boolean ToggleCommandTimeline(); + Boolean CommandTimelineOpen { get; }; + void Close(); Windows.Foundation.Size CharacterDimensions { get; }; Windows.Foundation.Size MinimumSize { get; }; Single SnapDimensionToGrid(Boolean widthOrHeight, Single dimension); diff --git a/src/cascadia/TerminalControl/TermControl.xaml b/src/cascadia/TerminalControl/TermControl.xaml index 6fa5e6aeb..0fef93263 100644 --- a/src/cascadia/TerminalControl/TermControl.xaml +++ b/src/cascadia/TerminalControl/TermControl.xaml @@ -1,4 +1,4 @@ - @@ -1244,7 +1244,8 @@ will solve a bunch of the search input issues we found last time we switched. --> - + @@ -1365,6 +1366,67 @@ + + + + + + + + + + + + + + + + + Toggle pane split orientation - - Toggle pane zoom - + + Toggle pane zoom + + + Toggle command timeline + Toggle pane read-only mode diff --git a/src/cascadia/TerminalSettingsModel/defaults.json b/src/cascadia/TerminalSettingsModel/defaults.json index 2037f4a92..be57538c9 100644 --- a/src/cascadia/TerminalSettingsModel/defaults.json +++ b/src/cascadia/TerminalSettingsModel/defaults.json @@ -915,7 +915,8 @@ { "command": { "action": "swapPane", "direction": "nextInOrder"}, "id": "Terminal.SwapPaneNextInOrder" }, { "command": { "action": "swapPane", "direction": "first" }, "id": "Terminal.SwapPaneFirst" }, { "command": "toggleBroadcastInput", "id": "Terminal.ToggleBroadcastInput" }, - { "command": "togglePaneZoom", "id": "Terminal.TogglePaneZoom" }, + { "command": "togglePaneZoom", "id": "Terminal.TogglePaneZoom" }, + { "command": "toggleCommandTimeline", "id": "winTerm.ToggleCommandTimeline" }, { "command": "toggleSplitOrientation", "id": "Terminal.ToggleSplitOrientation" }, { "command": "toggleReadOnlyMode", "id": "Terminal.ToggleReadOnlyMode" }, { "command": "enableReadOnlyMode", "id": "Terminal.EnableReadOnlyMode" }, @@ -1035,7 +1036,7 @@ // Tab Management // "command": "closeTab" is unbound by default. // The closeTab command closes a tab without confirmation, even if it has multiple panes. - { "keys": "ctrl+shift+t", "id": "Terminal.OpenNewTab" }, + { "keys": "ctrl+alt+t", "id": "Terminal.OpenNewTab" }, { "keys": "ctrl+shift+n", "id": "Terminal.OpenNewWindow" }, { "keys": "ctrl+shift+1", "id": "Terminal.OpenNewTabProfile0" }, { "keys": "ctrl+shift+2", "id": "Terminal.OpenNewTabProfile1" }, @@ -1047,8 +1048,9 @@ { "keys": "ctrl+shift+8", "id": "Terminal.OpenNewTabProfile7" }, { "keys": "ctrl+shift+9", "id": "Terminal.OpenNewTabProfile8" }, { "keys": "ctrl+shift+d", "id": "Terminal.DuplicateTab" }, - { "keys": "ctrl+tab", "id": "Terminal.NextTab" }, - { "keys": "ctrl+shift+tab", "id": "Terminal.PrevTab" }, + { "keys": "ctrl+tab", "id": "winTerm.ToggleCommandTimeline" }, + { "keys": "ctrl+t", "id": "Terminal.NextTab" }, + { "keys": "ctrl+shift+t", "id": "Terminal.PrevTab" }, { "keys": "ctrl+alt+1", "id": "Terminal.SwitchToTab0" }, { "keys": "ctrl+alt+2", "id": "Terminal.SwitchToTab1" }, { "keys": "ctrl+alt+3", "id": "Terminal.SwitchToTab2" }, diff --git a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp index 28381f9b1..934821679 100644 --- a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp +++ b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp @@ -30,6 +30,16 @@ namespace ControlUnitTests TEST_METHOD(CommandTextCacheIsBoundedAndOutputIsAbsent); TEST_METHOD(CloseClearsOwnedStateAndRejectsCallbacks); TEST_METHOD(NativeOscLifecycleFeedsPaneTimeline); + TEST_METHOD(NavigationEmptyOpenClosePreservesNoFakeEntry); + TEST_METHOD(NavigationInitialOpenAndRestoreSelection); + TEST_METHOD(NavigationMovesOneEntryAndDoesNotWrap); + TEST_METHOD(NavigationPageEdgesMoveOneInOneOut); + TEST_METHOD(NavigationPageFirstAndLastStayWithinViewport); + TEST_METHOD(NavigationHoverAndClickOnlySelectVisibleEntry); + TEST_METHOD(NavigationWheelAccumulatesReversesAndSettles); + TEST_METHOD(NavigationReconcilesRemovalReflowAndNewCommands); + TEST_METHOD(NavigationStateIsPaneLocalAndRepeatedCloseIsSafe); + TEST_METHOD(NavigationVisibleProjectionIsBoundedAndNeutralizesLimitedResults); TEST_CLASS_SETUP(ModuleSetup) { @@ -354,6 +364,14 @@ namespace ControlUnitTests return entry.id == preservedId; })); + const auto opened = core->OpenCommandTimeline(2); + VERIFY_ARE_EQUAL(size_t{ 2 }, opened.visibleEntries.size()); + core->CloseCommandTimelineOverlay(); + const auto reopened = core->OpenCommandTimeline(2); + VERIFY_ARE_EQUAL(size_t{ 2 }, reopened.visibleEntries.size()); + VERIFY_ARE_EQUAL(size_t{ 1 }, core->CommandTimelineSnapshot().bootstrapScanCount); + core->CloseCommandTimelineOverlay(); + CommandTimelineViewState viewState; viewState.selectedCommandId = resized.entries.back().id; viewState.visibleNativeAnchor = resized.entries.back().nativeMarkId; @@ -379,4 +397,268 @@ namespace ControlUnitTests settings = nullptr; cleanup.release(); } + + void CommandTimelineTests::NavigationEmptyOpenClosePreservesNoFakeEntry() + { + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + const std::vector entries; + + const auto opened = navigation.Open(entries, viewState, ShellIntegrationCapability::Unknown, 0); + VERIFY_IS_TRUE(opened.open); + VERIFY_IS_TRUE(opened.visibleEntries.empty()); + VERIFY_ARE_EQUAL(size_t{ 1 }, navigation.VisibleCapacity()); + VERIFY_IS_FALSE(viewState.selectedCommandId.has_value()); + + navigation.Close(); + navigation.Close(); + VERIFY_IS_FALSE(navigation.IsOpen()); + VERIFY_IS_FALSE(navigation.WheelSettlePending()); + } + + void CommandTimelineTests::NavigationInitialOpenAndRestoreSelection() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 5; ++nativeMark) + { + Execute(index, nativeMark, revision, std::to_wstring(nativeMark), 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + auto latest = navigation.Open(index.Entries(), viewState, index.Capability(), 3); + VERIFY_ARE_EQUAL(uint64_t{ 5 }, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(size_t{ 2 }, *viewState.selectedVisualSlot); + VERIFY_ARE_EQUAL(size_t{ 3 }, latest.visibleEntries.size()); + + navigation.Close(); + viewState.selectedCommandId = index.Entries()[3].id; + viewState.visibleNativeAnchor = index.Entries()[2].nativeMarkId; + viewState.selectedVisualSlot = 1; + const auto restored = navigation.Open(index.Entries(), viewState, index.Capability(), 3); + VERIFY_ARE_EQUAL(uint64_t{ 4 }, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, restored.visibleEntries.front().id.sequence); + VERIFY_ARE_EQUAL(size_t{ 1 }, restored.selectedVisualSlot); + } + + void CommandTimelineTests::NavigationMovesOneEntryAndDoesNotWrap() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 4; ++nativeMark) + { + Execute(index, nativeMark, revision, L"command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 2); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + + for (size_t step = 0; step < 5; ++step) + { + navigation.Navigate(NavigationAction::Next, index.Entries(), viewState, index.Capability()); + } + VERIFY_ARE_EQUAL(uint64_t{ 4 }, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::NavigationPageEdgesMoveOneInOneOut() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 6; ++nativeMark) + { + Execute(index, nativeMark, revision, L"command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + viewState.selectedCommandId = index.Entries()[2].id; + viewState.visibleNativeAnchor = index.Entries()[2].nativeMarkId; + viewState.selectedVisualSlot = 0; + const auto beforeUp = navigation.Open(index.Entries(), viewState, index.Capability(), 3); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, beforeUp.visibleEntries.front().id.sequence); + const auto afterUp = navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 3 }, afterUp.visibleEntries.size()); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, afterUp.visibleEntries.front().id.sequence); + VERIFY_ARE_EQUAL(uint64_t{ 4 }, afterUp.visibleEntries.back().id.sequence); + + viewState.selectedCommandId = index.Entries()[3].id; + viewState.visibleNativeAnchor = index.Entries()[1].nativeMarkId; + viewState.selectedVisualSlot = 2; + navigation.Close(); + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + const auto afterDown = navigation.Navigate(NavigationAction::Next, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 3 }, afterDown.visibleEntries.size()); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, afterDown.visibleEntries.front().id.sequence); + VERIFY_ARE_EQUAL(uint64_t{ 5 }, afterDown.visibleEntries.back().id.sequence); + } + + void CommandTimelineTests::NavigationPageFirstAndLastStayWithinViewport() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 5; ++nativeMark) + { + Execute(index, nativeMark, revision, L"command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + navigation.Navigate(NavigationAction::PageFirst, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + navigation.Navigate(NavigationAction::PageLast, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 5 }, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::NavigationHoverAndClickOnlySelectVisibleEntry() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"first", 0); + Execute(index, 2, revision, L"second", 0); + Execute(index, 3, revision, L"third", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + const auto hovered = navigation.SelectVisibleEntry(0, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + VERIFY_IS_TRUE(hovered.visibleEntries[0].selected); + VERIFY_IS_FALSE(viewState.loadedIntoInput); + VERIFY_ARE_EQUAL(uint64_t{ 0 }, viewState.executionGeneration); + + navigation.SelectVisibleEntry(2, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + VERIFY_IS_FALSE(viewState.loadedIntoInput); + } + + void CommandTimelineTests::NavigationWheelAccumulatesReversesAndSettles() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 5; ++nativeMark) + { + Execute(index, nativeMark, revision, L"command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + navigation.ApplyWheelDelta(60, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 5 }, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(60, navigation.WheelDeltaRemainder()); + + navigation.ApplyWheelDelta(-30, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(30, navigation.WheelDeltaRemainder()); + navigation.ApplyWheelDelta(90, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 4 }, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(0, navigation.WheelDeltaRemainder()); + navigation.ApplyWheelDelta(-1, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(-1, navigation.WheelDeltaRemainder()); + VERIFY_IS_TRUE(navigation.WheelSettlePending()); + navigation.SettleWheel(); + VERIFY_ARE_EQUAL(0, navigation.WheelDeltaRemainder()); + VERIFY_IS_FALSE(navigation.WheelSettlePending()); + + navigation.ApplyWheelDelta(360, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::NavigationReconcilesRemovalReflowAndNewCommands() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 4; ++nativeMark) + { + Execute(index, nativeMark, revision, L"command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 2); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + + Execute(index, 5, revision, L"new", 0); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 2); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + + const std::array removeSelected{ 3 }; + index.InvalidateNativeMarks(removeSelected, ++revision); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 2); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, viewState.selectedCommandId->sequence); + + viewState.selectedCommandId = index.Entries().back().id; + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 2); + Execute(index, 6, revision, L"latest", 0); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 2); + VERIFY_ARE_EQUAL(uint64_t{ 6 }, viewState.selectedCommandId->sequence); + + const auto stableId = viewState.selectedCommandId; + const std::array allSurvive{ 1, 2, 4, 5 }; + index.ReconcileReflow(allSurvive, ++revision); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 2); + VERIFY_IS_TRUE(viewState.selectedCommandId.has_value()); + VERIFY_ARE_NOT_EQUAL(stableId->sequence, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::NavigationStateIsPaneLocalAndRepeatedCloseIsSafe() + { + CommandTimelineIndex first{ PaneOne }; + CommandTimelineIndex second{ PaneTwo }; + uint64_t firstRevision = 0; + uint64_t secondRevision = 0; + Execute(first, 1, firstRevision, L"same", 0); + Execute(first, 2, firstRevision, L"same", 0); + Execute(second, 1, secondRevision, L"same", 0); + Execute(second, 2, secondRevision, L"same", 0); + + CommandTimelineNavigationModel firstNavigation; + CommandTimelineNavigationModel secondNavigation; + CommandTimelineViewState firstView; + CommandTimelineViewState secondView; + firstNavigation.Open(first.Entries(), firstView, first.Capability(), 1); + secondNavigation.Open(second.Entries(), secondView, second.Capability(), 1); + firstNavigation.ApplyWheelDelta(60, first.Entries(), firstView, first.Capability()); + VERIFY_ARE_EQUAL(60, firstNavigation.WheelDeltaRemainder()); + VERIFY_ARE_EQUAL(0, secondNavigation.WheelDeltaRemainder()); + + const auto preserved = firstView.selectedCommandId; + firstNavigation.Close(); + firstNavigation.Close(); + VERIFY_IS_TRUE(firstView.selectedCommandId == preserved); + VERIFY_IS_TRUE(secondNavigation.IsOpen()); + } + + void CommandTimelineTests::NavigationVisibleProjectionIsBoundedAndNeutralizesLimitedResults() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t nativeMark = 1; nativeMark <= 10; ++nativeMark) + { + Execute(index, nativeMark, revision, L"same command", 0); + } + + auto entries = index.Entries(); + entries.back().shellIntegrationCapability = ShellIntegrationCapability::Limited; + entries.back().executionResult = ExecutionResult::Succeeded; + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + const auto presentation = navigation.Open(entries, viewState, ShellIntegrationCapability::Limited, 2); + VERIFY_ARE_EQUAL(size_t{ 2 }, presentation.visibleEntries.size()); + VERIFY_ARE_EQUAL(std::wstring{ L"same command" }, presentation.visibleEntries[0].commandText); + VERIFY_ARE_EQUAL(std::wstring{ L"same command" }, presentation.visibleEntries[1].commandText); + VERIFY_ARE_NOT_EQUAL(presentation.visibleEntries[0].id.sequence, presentation.visibleEntries[1].id.sequence); + VERIFY_ARE_EQUAL(static_cast(ExecutionResult::Unknown), + static_cast(presentation.visibleEntries[1].executionResult)); + VERIFY_ARE_EQUAL(uint64_t{ 10 }, presentation.visibleEntries[1].id.sequence); + } } diff --git a/src/cascadia/UnitTests_SettingsModel/KeyBindingsTests.cpp b/src/cascadia/UnitTests_SettingsModel/KeyBindingsTests.cpp index 6499a55f3..fb8873303 100644 --- a/src/cascadia/UnitTests_SettingsModel/KeyBindingsTests.cpp +++ b/src/cascadia/UnitTests_SettingsModel/KeyBindingsTests.cpp @@ -41,6 +41,7 @@ namespace SettingsModelUnitTests TEST_METHOD(TestGetKeyBindingForAction); TEST_METHOD(KeybindingsWithoutVkey); TEST_METHOD(ControlCIsReservedForTerminalInput); + TEST_METHOD(CommandTimelineDefaultShortcutsAndUserOverride); }; void KeyBindingsTests::KeyChords() @@ -153,6 +154,37 @@ namespace SettingsModelUnitTests VERIFY_IS_TRUE(actionMap->_KeyMap.contains(controlV)); } + void KeyBindingsTests::CommandTimelineDefaultShortcutsAndUserOverride() + { + const auto settings = CascadiaSettings::LoadDefaults(); + const auto actionMap = settings.ActionMap(); + const auto verifyAction = [&](const KeyChord& chord, const ShortcutAction expected) { + const auto command = actionMap.GetActionByKeyChord(chord); + VERIFY_IS_NOT_NULL(command); + VERIFY_ARE_EQUAL(static_cast(expected), static_cast(command.ActionAndArgs().Action())); + }; + + verifyAction(KeyChord{ true, false, false, false, static_cast('T'), 0 }, ShortcutAction::NextTab); + verifyAction(KeyChord{ true, false, true, false, static_cast('T'), 0 }, ShortcutAction::PrevTab); + verifyAction(KeyChord{ true, true, false, false, static_cast('T'), 0 }, ShortcutAction::NewTab); + verifyAction(KeyChord{ true, false, false, false, VK_TAB, 0 }, ShortcutAction::ToggleCommandTimeline); + VERIFY_IS_NULL(actionMap.GetActionByKeyChord(KeyChord{ true, false, true, false, VK_TAB, 0 })); + + auto layered = winrt::make_self(); + layered->LayerJson(VerifyParseSucceeded(R"([ + { "command": "toggleCommandTimeline", "keys": "ctrl+tab" } + ])"), + OriginTag::InBox); + layered->LayerJson(VerifyParseSucceeded(R"([ + { "command": "paste", "keys": "ctrl+tab" } + ])"), + OriginTag::User); + const auto overridden = layered->GetActionByKeyChord(KeyChord{ true, false, false, false, VK_TAB, 0 }); + VERIFY_IS_NOT_NULL(overridden); + VERIFY_ARE_EQUAL(static_cast(ShortcutAction::PasteText), + static_cast(overridden.ActionAndArgs().Action())); + } + void KeyBindingsTests::LayerKeybindings() { const std::string bindings0String{ R"([ { "command": "copy", "keys": ["ctrl+c"] } ])" }; diff --git a/src/cascadia/WindowsTerminal/WindowsTerminal.rc b/src/cascadia/WindowsTerminal/WindowsTerminal.rc index bda7e6fb1..550abffe8 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,2,1,0 - PRODUCTVERSION 1,2,1,0 + FILEVERSION 1,2,2,0 + PRODUCTVERSION 1,2,2,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.2.1.0\0" + VALUE "FileVersion", "1.2.2.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.2.1\0" + VALUE "ProductVersion", "1.2.2\0" END END BLOCK "VarFileInfo" diff --git a/src/cascadia/wt/wt.rc b/src/cascadia/wt/wt.rc index ab753a000..20842e059 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,2,1,0 - PRODUCTVERSION 1,2,1,0 + FILEVERSION 1,2,2,0 + PRODUCTVERSION 1,2,2,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.2.1.0\0" + VALUE "FileVersion", "1.2.2.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.2.1\0" + VALUE "ProductVersion", "1.2.2\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 d818011b1..27edbde3a 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,2,1,0 - PRODUCTVERSION 1,2,1,0 + FILEVERSION 1,2,2,0 + PRODUCTVERSION 1,2,2,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.2.1.0\0" + VALUE "FileVersion", "1.2.2.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.2.1\0" + VALUE "ProductVersion", "1.2.2\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm/Branding/ReleaseMetadata.h b/src/winterm/Branding/ReleaseMetadata.h index e2933203a..1d00f0058 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.2.1" }; + inline constexpr std::wstring_view ApplicationVersion{ L"1.2.2" }; 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 9c6573da4..3a444f805 100644 --- a/src/winterm/Branding/version.json +++ b/src/winterm/Branding/version.json @@ -1,10 +1,10 @@ { - "applicationVersion": "1.2.1", - "packageVersion": "1.2.1.0", - "moduleVersion": "1.2.1", + "applicationVersion": "1.2.2", + "packageVersion": "1.2.2.0", + "moduleVersion": "1.2.2", "modulePrerelease": "", "channel": "stable", - "tag": "v1.2.1", + "tag": "v1.2.2", "workspaceSchemaVersion": 2, "dockingModelVersion": 1, "shellProtocolVersion": 1, diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.cpp b/src/winterm/CommandTimeline/CommandTimelineModel.cpp index 43017795d..f5bc87df6 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.cpp +++ b/src/winterm/CommandTimeline/CommandTimelineModel.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace winTerm::CommandTimeline @@ -38,6 +39,366 @@ namespace winTerm::CommandTimeline executionGeneration = 0; } + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::Open( + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability, + const size_t visibleCapacity) + { + _open = true; + _visibleCapacity = std::max(1, visibleCapacity); + _wheelDeltaRemainder = 0; + _wheelSettlePending = false; + _reconcile(entries, viewState, false); + return _snapshot(entries, capability); + } + + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::Reconcile( + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability, + const size_t visibleCapacity) + { + _visibleCapacity = std::max(1, visibleCapacity); + if (_open) + { + _reconcile(entries, viewState, true); + } + return _snapshot(entries, capability); + } + + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::Navigate( + const NavigationAction action, + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability) + { + if (_open) + { + _reconcile(entries, viewState, false); + _moveSelection(action, entries); + _syncViewState(entries, viewState); + } + return _snapshot(entries, capability); + } + + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::SelectVisibleEntry( + const size_t visualSlot, + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability) + { + if (_open) + { + _reconcile(entries, viewState, false); + const auto index = _firstVisibleIndex + visualSlot; + if (visualSlot < _visibleCapacity && index < entries.size()) + { + _selectedIndex = index; + _syncViewState(entries, viewState); + } + } + return _snapshot(entries, capability); + } + + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::ApplyWheelDelta( + const int delta, + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability, + const int deltaPerEntry) + { + if (!_open || entries.empty() || delta == 0 || deltaPerEntry <= 0) + { + return _snapshot(entries, capability); + } + + _reconcile(entries, viewState, false); + _wheelSettlePending = true; + const auto accumulated = static_cast(_wheelDeltaRemainder) + delta; + _wheelDeltaRemainder = gsl::narrow_cast(std::clamp( + accumulated, + std::numeric_limits::min(), + std::numeric_limits::max())); + + while (_wheelDeltaRemainder >= deltaPerEntry) + { + _wheelDeltaRemainder -= deltaPerEntry; + if (!_moveSelection(NavigationAction::Previous, entries)) + { + _wheelDeltaRemainder = 0; + break; + } + } + while (_wheelDeltaRemainder <= -deltaPerEntry) + { + _wheelDeltaRemainder += deltaPerEntry; + if (!_moveSelection(NavigationAction::Next, entries)) + { + _wheelDeltaRemainder = 0; + break; + } + } + + _syncViewState(entries, viewState); + return _snapshot(entries, capability); + } + + void CommandTimelineNavigationModel::SettleWheel() noexcept + { + _wheelDeltaRemainder = 0; + _wheelSettlePending = false; + } + + void CommandTimelineNavigationModel::Close() noexcept + { + _selectedIndex.reset(); + _lastLatestCommandId.reset(); + _firstVisibleIndex = 0; + _visibleCapacity = 1; + _wheelDeltaRemainder = 0; + _open = false; + _wheelSettlePending = false; + } + + bool CommandTimelineNavigationModel::IsOpen() const noexcept + { + return _open; + } + + bool CommandTimelineNavigationModel::WheelSettlePending() const noexcept + { + return _wheelSettlePending; + } + + int CommandTimelineNavigationModel::WheelDeltaRemainder() const noexcept + { + return _wheelDeltaRemainder; + } + + size_t CommandTimelineNavigationModel::VisibleCapacity() const noexcept + { + return _visibleCapacity; + } + + void CommandTimelineNavigationModel::_reconcile(const std::span entries, + CommandTimelineViewState& viewState, + const bool allowFollowLatest) + { + if (entries.empty()) + { + _selectedIndex.reset(); + _lastLatestCommandId.reset(); + _firstVisibleIndex = 0; + _syncViewState(entries, viewState); + return; + } + + const auto wasFollowingLatest = allowFollowLatest && + _lastLatestCommandId.has_value() && + viewState.selectedCommandId == _lastLatestCommandId; + std::optional selected; + if (wasFollowingLatest) + { + selected = entries.size() - 1; + } + else if (viewState.selectedCommandId.has_value()) + { + selected = _findCommand(entries, *viewState.selectedCommandId); + if (!selected.has_value()) + { + selected = _findNearestCommand(entries, *viewState.selectedCommandId); + } + } + else + { + selected = entries.size() - 1; + } + _selectedIndex = selected; + + const auto maxFirst = entries.size() > _visibleCapacity ? entries.size() - _visibleCapacity : 0; + std::optional restoredAnchor; + if (viewState.visibleNativeAnchor.has_value()) + { + const auto anchor = std::find_if(entries.begin(), entries.end(), [&](const auto& entry) { + return entry.nativeMarkId == *viewState.visibleNativeAnchor; + }); + if (anchor != entries.end()) + { + restoredAnchor = gsl::narrow_cast(std::distance(entries.begin(), anchor)); + } + } + + if (restoredAnchor.has_value()) + { + _firstVisibleIndex = std::min(*restoredAnchor, maxFirst); + } + else + { + const auto desiredSlot = std::min(viewState.selectedVisualSlot.value_or(_visibleCapacity - 1), + _visibleCapacity - 1); + _firstVisibleIndex = *_selectedIndex > desiredSlot ? *_selectedIndex - desiredSlot : 0; + _firstVisibleIndex = std::min(_firstVisibleIndex, maxFirst); + } + + if (*_selectedIndex < _firstVisibleIndex) + { + _firstVisibleIndex = *_selectedIndex; + } + else if (*_selectedIndex >= _firstVisibleIndex + _visibleCapacity) + { + _firstVisibleIndex = *_selectedIndex - _visibleCapacity + 1; + } + _firstVisibleIndex = std::min(_firstVisibleIndex, maxFirst); + _lastLatestCommandId = entries.back().id; + _syncViewState(entries, viewState); + } + + bool CommandTimelineNavigationModel::_moveSelection(const NavigationAction action, + const std::span entries) + { + if (!_selectedIndex.has_value() || entries.empty()) + { + return false; + } + + auto next = *_selectedIndex; + switch (action) + { + case NavigationAction::Previous: + if (next == 0) + { + return false; + } + --next; + break; + case NavigationAction::Next: + if (next + 1 >= entries.size()) + { + return false; + } + ++next; + break; + case NavigationAction::PageFirst: + next = _firstVisibleIndex; + break; + case NavigationAction::PageLast: + next = std::min(entries.size(), _firstVisibleIndex + _visibleCapacity) - 1; + break; + } + + _selectedIndex = next; + if (next < _firstVisibleIndex) + { + _firstVisibleIndex = next; + } + else if (next >= _firstVisibleIndex + _visibleCapacity) + { + _firstVisibleIndex = next - _visibleCapacity + 1; + } + return true; + } + + void CommandTimelineNavigationModel::_syncViewState(const std::span entries, + CommandTimelineViewState& viewState) const + { + if (!_selectedIndex.has_value() || entries.empty()) + { + viewState.selectedCommandId.reset(); + viewState.visibleNativeAnchor.reset(); + viewState.selectedVisualSlot.reset(); + return; + } + + viewState.selectedCommandId = entries[*_selectedIndex].id; + viewState.visibleNativeAnchor = entries[_firstVisibleIndex].nativeMarkId; + viewState.selectedVisualSlot = *_selectedIndex - _firstVisibleIndex; + } + + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::_snapshot( + const std::span entries, + const ShellIntegrationCapability capability) const + { + CommandTimelinePresentationSnapshot result{ + .capability = capability, + .totalEntryCount = entries.size(), + .firstVisibleIndex = _firstVisibleIndex, + .selectedVisualSlot = _selectedIndex.has_value() ? *_selectedIndex - _firstVisibleIndex : 0, + .wheelDeltaRemainder = _wheelDeltaRemainder, + .open = _open, + .wheelSettlePending = _wheelSettlePending, + }; + if (!_open || entries.empty()) + { + return result; + } + + const auto end = std::min(entries.size(), _firstVisibleIndex + _visibleCapacity); + result.visibleEntries.reserve(end - _firstVisibleIndex); + for (auto index = _firstVisibleIndex; index < end; ++index) + { + const auto& entry = entries[index]; + result.visibleEntries.emplace_back(CommandTimelineVisibleEntry{ + .id = entry.id, + .commandText = entry.cachedCommandText, + .executionResult = _effectiveResult(entry), + .selected = _selectedIndex == index, + }); + } + return result; + } + + std::optional CommandTimelineNavigationModel::_findCommand( + const std::span entries, + const CommandId& id) noexcept + { + const auto found = std::find_if(entries.begin(), entries.end(), [&](const auto& entry) { + return entry.id == id; + }); + if (found == entries.end()) + { + return std::nullopt; + } + return gsl::narrow_cast(std::distance(entries.begin(), found)); + } + + size_t CommandTimelineNavigationModel::_findNearestCommand( + const std::span entries, + const CommandId& id) noexcept + { + if (id.paneSessionId != entries.front().id.paneSessionId) + { + return entries.size() - 1; + } + const auto next = std::lower_bound(entries.begin(), entries.end(), id.sequence, [](const auto& entry, const auto sequence) { + return entry.id.sequence < sequence; + }); + if (next == entries.begin()) + { + return 0; + } + if (next == entries.end()) + { + return entries.size() - 1; + } + + const auto nextIndex = gsl::narrow_cast(std::distance(entries.begin(), next)); + const auto previousIndex = nextIndex - 1; + const auto nextDistance = next->id.sequence - id.sequence; + const auto previousDistance = id.sequence - entries[previousIndex].id.sequence; + return previousDistance <= nextDistance ? previousIndex : nextIndex; + } + + ExecutionResult CommandTimelineNavigationModel::_effectiveResult(const CommandTimelineEntry& entry) noexcept + { + if (entry.shellIntegrationCapability != ShellIntegrationCapability::Full && + (entry.executionResult == ExecutionResult::Succeeded || entry.executionResult == ExecutionResult::Failed)) + { + return ExecutionResult::Unknown; + } + return entry.executionResult; + } + std::wstring CommandTextCachePolicy::Apply(const std::wstring_view commandText) const { if (commandText.size() <= maxCommandTextLength) diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.h b/src/winterm/CommandTimeline/CommandTimelineModel.h index 95fad09ea..9cd05948d 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.h +++ b/src/winterm/CommandTimeline/CommandTimelineModel.h @@ -97,6 +97,93 @@ namespace winTerm::CommandTimeline bool closed{ false }; }; + enum class NavigationAction : uint8_t + { + Previous, + Next, + PageFirst, + PageLast, + }; + + struct CommandTimelineVisibleEntry + { + CommandId id{}; + std::wstring commandText; + ExecutionResult executionResult{ ExecutionResult::Unknown }; + bool selected{ false }; + }; + + struct CommandTimelinePresentationSnapshot + { + std::vector visibleEntries; + ShellIntegrationCapability capability{ ShellIntegrationCapability::Unknown }; + size_t totalEntryCount{}; + size_t firstVisibleIndex{}; + size_t selectedVisualSlot{}; + int wheelDeltaRemainder{}; + bool open{ false }; + bool wheelSettlePending{ false }; + }; + + class CommandTimelineNavigationModel final + { + public: + static constexpr int DefaultWheelDeltaPerEntry = 120; + + CommandTimelinePresentationSnapshot Open(std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability, + size_t visibleCapacity); + CommandTimelinePresentationSnapshot Reconcile(std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability, + size_t visibleCapacity); + CommandTimelinePresentationSnapshot Navigate(NavigationAction action, + std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability); + CommandTimelinePresentationSnapshot SelectVisibleEntry(size_t visualSlot, + std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability); + CommandTimelinePresentationSnapshot ApplyWheelDelta(int delta, + std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability, + int deltaPerEntry = DefaultWheelDeltaPerEntry); + void SettleWheel() noexcept; + void Close() noexcept; + + bool IsOpen() const noexcept; + bool WheelSettlePending() const noexcept; + int WheelDeltaRemainder() const noexcept; + size_t VisibleCapacity() const noexcept; + + private: + void _reconcile(std::span entries, + CommandTimelineViewState& viewState, + bool allowFollowLatest); + bool _moveSelection(NavigationAction action, + std::span entries); + void _syncViewState(std::span entries, + CommandTimelineViewState& viewState) const; + CommandTimelinePresentationSnapshot _snapshot(std::span entries, + ShellIntegrationCapability capability) const; + static std::optional _findCommand(std::span entries, + const CommandId& id) noexcept; + static size_t _findNearestCommand(std::span entries, + const CommandId& id) noexcept; + static ExecutionResult _effectiveResult(const CommandTimelineEntry& entry) noexcept; + + std::optional _selectedIndex; + std::optional _lastLatestCommandId; + size_t _firstVisibleIndex{}; + size_t _visibleCapacity{ 1 }; + int _wheelDeltaRemainder{}; + bool _open{ false }; + bool _wheelSettlePending{ false }; + }; + struct NativeMarkSnapshot { uint64_t nativeMarkId{}; diff --git a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h index b00fa750f..2346fd267 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.2.1" }; + std::string applicationVersion{ "1.2.2" }; 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 66e4cb614..214a6ce1d 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.2.1"); + workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.2.2"); workspace.protocolVersion = UIntOrDefault(json, "protocolVersion", 1); workspace.dockingModelVersion = UIntOrDefault(json, "dockingModelVersion", DockingModelVersion); if (const auto& startup = json["startupBehavior"]; !startup.isNull())