diff --git a/CHANGELOG.md b/CHANGELOG.md index 6037ed87e..8815eb5e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## 1.2.4 - 2026-08-04 + +### Added + +- Added pane-local Command Timeline search. `/` or Tab moves focus to the filter + box, typing filters the current pane's commands, Up and Down walk the filtered + results, and Enter loads the selected command without executing it. Neither + the focus keys nor the query text ever reach the PTY. +- Added the `commandTimeline.enabled` and `commandTimeline.historyLimit` global + settings, exposed under Settings → Appearance → Command timeline. Defaults are + `true` and `500`; the history limit accepts 50 through 5000 per pane. +- Added four distinct empty states so an unsupported shell is never reported as + simply having run no commands: waiting for shell integration, command timeline + unavailable, no commands yet, and no matching commands. +- Added bounded per-pane history with oldest-first eviction, plus deterministic + coverage for filtered navigation, wheel accumulation over the filtered + projection, surrogate-safe query truncation, eviction, and a 5000-entry + worst-case search. + +### Changed + +- Search is a literal, case-insensitive substring match over each pane's bounded + in-memory command text only. There is no regex, no fuzzy matching, no output + search, and no terminal-buffer rescan. +- Queries are capped at 256 UTF-16 code units and truncated without leaving a + lone surrogate. A query is never persisted: closing the overlay releases the + query, the filtered projection, and every materialized row. +- The filtered projection keeps stable `CommandId` identity. A command that + still matches stays selected, a command that stops matching hands selection to + the nearest surviving match, and a new command only takes the selection when it + matches and the view was already following the latest command. +- Escape now clears a non-empty query first and only closes the overlay once the + query is already empty. +- Lowering `commandTimeline.historyLimit` evicts oldest-first immediately on + panes that already exist. Raising it never resurrects an evicted command, and + sequence IDs are never reused. +- Disabling `commandTimeline.enabled` hides the left-side handle and closes an + overlay that is already open; the toggle shortcut no longer opens it. +- Advanced engineering application and PowerShell module versions to `1.2.4`, + package/file versions to `1.2.4.0`, and the intended checkpoint tag to + `v1.2.4`; workspace, docking, shell, theme, update-manifest, package identity, + and signing-policy versions remain unchanged. + +### Checkpoint status + +- `v1.2.4` is an engineering checkpoint for Command Timeline Phase 4, not a + public GitHub Release. GitHub Latest and README public downloads remain on + v1.2.0. There is still no persistent history, no output cache, and no + telemetry. +- Builds on Command Timeline Phase 3, squash-merged to `main` as `5fd2172` + through pull request #29. + ## 1.2.3 - 2026-08-03 ### Added diff --git a/doc/cascadia/profiles.schema.json b/doc/cascadia/profiles.schema.json index bc2997a8a..1cc61ff3d 100644 --- a/doc/cascadia/profiles.schema.json +++ b/doc/cascadia/profiles.schema.json @@ -2378,6 +2378,18 @@ "description": "When set to true, a selection is immediately copied to your clipboard upon creation. When set to false, the selection persists and awaits further action.", "type": "boolean" }, + "commandTimeline.enabled": { + "default": true, + "description": "When set to true, the Command Timeline overlay and its left-side handle are available for each pane. When set to false, the handle is hidden and the toggle shortcut does not open the overlay.", + "type": "boolean" + }, + "commandTimeline.historyLimit": { + "default": 500, + "description": "The maximum number of commands each pane keeps in its in-memory Command Timeline. Lowering this evicts the oldest commands immediately. History is never written to disk.", + "maximum": 5000, + "minimum": 50, + "type": "integer" + }, "focusFollowMouse": { "default": false, "description": "When set to true, the terminal will focus the pane on mouse hover.", diff --git a/docs/current-progress.md b/docs/current-progress.md index 1eb04e553..6549fccd6 100644 --- a/docs/current-progress.md +++ b/docs/current-progress.md @@ -1,21 +1,22 @@ # Current development progress -Last updated: 2026-08-03 +Last updated: 2026-08-04 ## Repository state -- Branch: `feature/command-timeline-v1.3.0` -- Starting commit: `395f9becd` (`main` after Command Timeline Phase 2) +- Branch: `feature/command-timeline-v1.3.0-phase4` +- Base branch: `main`. Command Timeline Phase 3 (tag `v1.2.3`) was squash-merged + to `main` as `5fd2172` through pull request #29. - Microsoft Terminal upstream revision: `1cea42d433253d95c4487a3037db48197b5e72f4` -- Engineering application and PowerShell module version: `1.2.3` -- Engineering package version: `1.2.3.0` -- Intended checkpoint tag: `v1.2.3` +- Engineering application and PowerShell module version: `1.2.4` +- Engineering package version: `1.2.4.0` +- Intended checkpoint tag: `v1.2.4` - Final Command Timeline release target: `v1.3.0` - Current public Latest: `v1.2.0` - Supported target: Windows 11 x64 -`v1.2.3` is a development checkpoint, not a distributable release. The README +`v1.2.4` 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,46 +24,48 @@ and GitHub Release jobs. ## Implemented in the working tree -- Retained the Phase 1 pane-owned index and the Phase 2 navigation model, - overlay, wheel accumulation, and accessibility surface unchanged as the only - history data source and presentation path. -- Added a pane-owned pure C++ `CommandTimelineActionModel`. It decides whether - a load, copy, or jump is possible from the stable selected `CommandId`, and - never resolves output, reads the clipboard, or produces a payload containing - a carriage return. -- Added Enter and single-click load onto the focused pane's input line. The - payload is filtered for control codes only, `CarriageReturnNewline` is - deliberately not applied, no carriage return is appended, and `SendInput` - targets this pane's connection, so the load can never execute, never reads - the Windows clipboard, and is never forwarded by input broadcast. -- Added multi-line and large-load protection: a multi-line command is refused - when the shell has not enabled bracketed paste, and a load above 1024 - characters requires a confirming Enter. Escape cancels a pending confirmation - before it closes the overlay. -- Added Space to jump the viewport to the selected command's native mark, and a - per-entry context menu with copy command, copy output, and jump to output. - Ctrl+C copies the selected command while the Timeline owns the keyboard. -- Added on-demand output resolution through - `Terminal::ResolveCommandTimelineOutput`. Output is read from the buffer only - for an explicit copy action and is never cached, indexed, or retained. -- Added `loadedCommandId` plus execution-generation tracking. `CommandStart` - retires the loaded state, `IsCurrentGeneration` detects a late completion from - a retired command, and `ReconcileLoadedInput` releases loaded-input state when - the loaded command is evicted. -- Advanced authoritative engineering version surfaces to `1.2.3`/`1.2.3.0` and - added the v1.2.3 root changelog entry. -- Kept Phase 4 out of scope: there is no search box, no filtering, and no - public `commandTimeline.*` settings yet. +- Retained the Phase 1 index, the Phase 2 overlay and navigation model, and the + Phase 3 load/copy/jump entry actions unchanged in behavior. +- Added pane-local search over each pane's bounded in-memory command text. The + match is a literal case-insensitive substring search; there is no regex, no + fuzzy matching, no output search, and no terminal-buffer rescan. +- Added a 256 UTF-16 code-unit query cap that truncates without leaving a lone + surrogate, enforced in the model and mirrored by `MaxLength` on the filter box. +- Reworked the navigation model to walk a filtered projection while keeping + stable `CommandId` identity. A still-matching command stays selected, a command + that stops matching hands selection to the nearest surviving match, and a new + command only takes the selection when it matches and the view was already + following the latest command. +- Added `/` and Tab to focus the filter box. Both are consumed before the PTY, + and filter-box text never reaches the shell. Escape now clears a non-empty + query before it closes the overlay. +- Added the `commandTimeline.enabled` and `commandTimeline.historyLimit` global + settings with defaults `true` and `500`, a 50–5000 clamped range, JSON schema + entries, and a Settings UI section under Appearance. An absent setting is not + serialized back, so existing settings files need no migration. +- Added bounded per-pane history with oldest-first eviction that applies to + panes that already exist and to new panes. Raising the limit never resurrects + an evicted command and sequence IDs are never reused. +- Added four distinct empty states so an unsupported shell is never reported as + simply having run no commands. +- Made list item position and set size reflect the filtered result count, and + kept localized accessible names, non-color status, High Contrast theme + resources, and the Reduced Motion-safe no-animation path. +- Advanced authoritative engineering version surfaces to `1.2.4`/`1.2.4.0` and + added the v1.2.4 root changelog entry. +- Kept `v1.3.0-alpha` out of scope: no persistent history, no output cache, no + telemetry, and no public release work. ## Validation state -Phase 3 validation requires the focused Command Timeline model/control tests, -the extended `test-command-timeline.ps1` source and privacy boundaries, version -and branding verification, release/CI classification guards, shell integration -checks, repository Smoke validation, the smallest affected native projects, 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. +Phase 4 validation requires the focused Command Timeline model/control tests, +the Settings Model Command Timeline tests, the extended +`test-command-timeline.ps1` source, search, settings, and privacy boundaries, +version and branding verification, release/CI classification guards, repository +Smoke validation, the smallest affected native projects, 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 annotated `v1.2.3` checkpoint tag must point to the final commit that passes +The annotated `v1.2.4` 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/command-timeline-phase4.md b/docs/development/command-timeline-phase4.md new file mode 100644 index 000000000..a90402399 --- /dev/null +++ b/docs/development/command-timeline-phase4.md @@ -0,0 +1,182 @@ +# Command Timeline Phase 4 — search, settings, and bounded history + +Phase 4 completes the in-memory Command Timeline feature surface: pane-local +search and filtering, the two public settings, trustworthy shell degradation +states, and bounded history with oldest-first eviction. + +Engineering checkpoint: `1.2.4` / `1.2.4.0`, tag `v1.2.4`. This is not a public +release; GitHub Latest stays on v1.2.0. + +## Scope + +| In scope | Out of scope | +| --- | --- | +| Pane-local literal search | Regex or fuzzy search | +| Filtered projection with stable identity | Output search or indexing | +| `commandTimeline.enabled` / `.historyLimit` | Persistent history | +| Settings UI under Appearance | Output cache | +| Four distinct empty states | Telemetry | +| Bounded history, oldest-first eviction | `v1.3.0-alpha` work | + +## Search semantics + +`CommandTimelineQueryMatches` is a case-insensitive literal substring search +implemented with `std::search` and `towlower`. It is deliberately not a regex +and deliberately not fuzzy, and `test-command-timeline.ps1` fails the build if +`std::regex`, `regex_search`, or a fuzzy matcher appears in the model. + +Filtering reads `entry.cachedCommandText` only — the same bounded 4096-character +cache Phase 1 established. Output is never consulted, and no terminal-buffer +scan is triggered. `_rebuildFilter` walks the existing in-memory index; a new +command is reconciled through the same incremental path that already existed. + +### Query bounds + +`NormalizeCommandTimelineQuery` caps the query at +`MaxCommandTimelineQueryLength` (256 UTF-16 code units). If the cut would land +between a high and low surrogate, the orphaned lead unit is dropped, so the +result is never a lone surrogate. The XAML `TextBox` also carries +`MaxLength="256"`, and the control writes the normalized value back into the box +when truncation shortens it. + +## Filtered projection + +The navigation model keeps `_filtered`, a vector of indices into the caller's +entries span, and navigates over *positions within `_filtered`* rather than over +raw entry indices. An empty query fills `_filtered` with every index, so the +unfiltered case walks exactly the same code path. + +Selection reconciliation: + +| Situation | Result | +| --- | --- | +| Selected command still matches | Stays selected | +| Selected command stops matching | Nearest surviving match (`_nearestPosition`) | +| Following latest, new command matches | New command becomes the selection | +| Following latest, new command does not match | Selection unchanged | +| Browsing older history, new command arrives | Selection unchanged | +| No results | Selected `CommandId` retained, nothing projected | + +Following-latest additionally requires that the newest command is itself in the +projection, which is what stops a non-matching new command from pulling the +selection anywhere. + +Every action still resolves through `viewState.selectedCommandId`, so filtered +navigation, hover, click, wheel, copy, load, jump, and the context menu all act +on the same stable command. + +Only `visibleCapacity` rows are ever materialized, whatever the size of the +history behind them. + +## Settings + +| Setting | Default | Range | Effect | +| --- | ---: | --- | --- | +| `commandTimeline.enabled` | `true` | — | Overlay and left-side handle | +| `commandTimeline.historyLimit` | `500` | 50–5000, integer | Per-pane history | + +Plumbing, in order: `MTSMSettings.h` (`MTSM_GLOBAL_SETTINGS`) → +`GlobalAppSettings.idl` → `ControlProperties.h` → `IControlSettings.idl` → +`TerminalSettings.cpp` → `ControlCore`. Defaults live in `defaults.json`; the +JSON schema in `doc/cascadia/profiles.schema.json` carries type, default, +`minimum`, and `maximum`. + +An absent setting is not written back on serialization, so an existing settings +file needs no migration. An out-of-range value is accepted by the parser and +clamped by `ClampCommandTimelineHistoryLimit`, so the runtime value is always +within 50–5000 rather than failing the whole settings load. + +`UpdateSettings` applies the limit to panes that already exist, and the index +constructor applies it to new panes. + +Disabling the feature hides the handle and closes an open overlay +(`_applyCommandTimelineEnabledSetting`), and `ToggleCommandTimeline` refuses to +open while disabled. + +## History limit and eviction + +`CommandTimelineIndex::_applyHistoryLimit` erases from the front until the +history fits, and runs on entry creation, on bootstrap, and on +`SetHistoryLimit`. Consequences, all covered by tests: + +- Lowering the limit evicts immediately, oldest first. +- Raising the limit never resurrects an evicted entry. +- `_nextSequence` only ever increases, so sequence IDs are never reused. +- `ReconcileLoadedInput` releases loaded-input state when the loaded command is + evicted. + +## Shell degradation + +`CommandTimelineEmptyState` distinguishes four cases so an unsupported shell is +never presented as an empty history: + +| State | Condition | Message | +| --- | --- | --- | +| `WaitingForShell` | Capability `Unknown`, no entries | Waiting for shell integration | +| `ShellUnsupported` | Capability `Limited` | Command timeline unavailable | +| `NoCommands` | Capability `Full`, no entries | No commands yet | +| `NoMatchingCommands` | Query non-empty, entries exist, no matches | No matching commands | + +No prompt parser, no heuristic output detection, and no ConPTY, VT parser, +TextBuffer, renderer, or shell protocol change. + +## Input isolation + +| Key | Timeline focus | Filter-box focus | +| --- | --- | --- | +| `/` | Focus filter box | Types `/` | +| Tab | Focus filter box | Types/moves per text box | +| Up / Down | Move selection | Move selection | +| Left / Right | Page edges | Caret editing | +| Enter | Load, never execute | Load, never execute | +| Escape | Cancel confirmation → clear query → close | Clear query → close | +| Ctrl+C | Copy selected command | Text box copy | + +`/` and Tab are consumed by `_tryHandleCommandTimelineKey`, so neither reaches +the PTY. Filter-box text never reaches the PTY because the `TextBox` owns the +input; `_CommandTimelineSearchKeyDown` claims only Up, Down, Enter, and Escape +and leaves everything else — including IME/TSF composition — to the text box. +`_commandTimelineConsumedKeys` still de-duplicates key-down/key-up so a consumed +key never leaks on release. + +`Ctrl+Tab` and user-defined key bindings keep precedence because +`_TryHandleKeyBinding` runs before the Timeline handler. + +## Accessibility + +- The filter box has a localized accessible name and placeholder. +- List item `PositionInSet` and `SizeOfSet` use the **filtered** result count, + so assistive technology announces a position within the matches. +- Empty states are localized resources, and status is never conveyed by color + alone. +- The overlay uses `{ThemeResource}` brushes for High Contrast and adds no + storyboard or continuous animation. +- Geometry is in device-independent pixels; the overlay changes no terminal + rows/columns, pane size, PTY size, swap-chain size, or padding. + +## Performance evidence + +`SearchStressAtMaximumHistoryLimit` builds a full 5000-entry history, then: + +- Filters with a query matching all 5000 — projection reports 5000 matches while + materializing exactly `visibleCapacity` (20) rows. +- Runs 25 passes of narrow → no-result → broad filtering, asserting the + projection and the materialized row count return to their expected values each + pass, so repeated filtering does not accumulate. +- Asserts cached command text stays within + `5000 * DefaultMaxCachedCommandText`. +- Closes and asserts the filtered projection is released. + +Measured result is recorded in the pull request rather than described as +"performs well". + +## Validation + +- `scripts/winterm/test-command-timeline.ps1` — extended with Phase 4 guards for + search literalness, query bounds, settings defaults/range/schema, Settings UI + presence, and the filter reading only cached command text. +- `src/cascadia/UnitTests_Control/CommandTimelineTests.cpp` — the `Search*`, + `HistoryLimit*`, and `ShellDegradation*` tests. +- `src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp` — + settings defaults, JSON round-trip, runtime clamping, and out-of-range + handling. diff --git a/docs/user/command-timeline.md b/docs/user/command-timeline.md index 394a8d7ed..33f77f6b0 100644 --- a/docs/user/command-timeline.md +++ b/docs/user/command-timeline.md @@ -31,8 +31,10 @@ guesses where a prompt or output begins. | Situation | What you see | | --- | --- | -| Shell integration is working, no commands yet | `No commands yet` | +| winTerm has not determined the shell's capability yet | `Waiting for shell integration` | | Shell does not report complete command boundaries | `Command timeline unavailable` | +| Shell integration is working, no commands yet | `No commands yet` | +| A filter is active and nothing matches | `No matching commands` | PowerShell with winTerm shell integration reports full boundaries. `cmd.exe` does not, and winTerm does not add a prompt parser for it, so the Timeline @@ -43,16 +45,56 @@ stays unavailable there rather than showing untrustworthy results. | Key | Action | | --- | --- | | `Ctrl+Tab` | Toggle the Timeline for the focused pane | +| `/` or `Tab` | Move focus to the filter box | | `Up` / `Down` | Move the selection by one command | | `Left` / `Right` | Select the first / last command on the current page | | `Enter` | Load the selected command onto the input line | | `Space` | Scroll the terminal to that command's output | | `Ctrl+C` | Copy the selected command text | -| `Escape` | Close the Timeline | +| `Escape` | Clear the filter, or close the Timeline if it is already empty | Your own key bindings take precedence over these. If you have bound one of these keys yourself, your binding wins. +Nothing you type while the Timeline has focus reaches the shell. `/` and `Tab` +move focus to the filter box instead of being sent, and the text you type into +the filter box stays in the filter box. + +## Filtering + +Press `/` or `Tab` and start typing to narrow the list to commands containing +what you typed. + +- Matching is a plain, case-insensitive substring match. `git` matches + `GIT status`. There are no wildcards, no regular expressions, and no fuzzy + matching — `g.*s` matches only the literal text `g.*s`. +- Only command text is searched. Command output is never searched. +- Queries are limited to 256 characters. +- The filter is not remembered. Closing the Timeline clears it. + +While filtering, `Up` and `Down` move between matches, and `Enter` still loads +the selected command without running it. If the command you had selected stops +matching, the closest remaining match is selected instead. If nothing matches, +you see `No matching commands` and your selection is left alone. + +## Settings + +Settings → Appearance → Command timeline: + +| Setting | Default | Range | +| --- | ---: | --- | +| Show command timeline (`commandTimeline.enabled`) | On | — | +| Commands remembered per pane (`commandTimeline.historyLimit`) | 500 | 50–5000 | + +Turning the Timeline off hides the handle and closes it if it is open; the +`Ctrl+Tab` shortcut stops opening it. + +Lowering the history limit discards the oldest commands immediately. Raising it +again does not bring them back. Each pane counts its own history separately. + +You do not need to edit your settings file for these to apply — existing panes +pick up the change straight away. + ## Loading a command Press `Enter`, or click a row once, to put the selected command on the input @@ -94,12 +136,14 @@ a copy of command output. while that pane is open. Closing the pane discards it. - Nothing is written to disk. There is no command history file or database. - Command output is never cached, indexed, or searched. +- Filter text is never saved and never leaves the pane. - Nothing is sent anywhere. The Timeline writes no telemetry and logs no - commands, output, or paths. + commands, output, paths, or filter text. - The clipboard is only written when you explicitly choose a copy action, and is never read. - Each command's stored text is capped at 4096 characters. -- Each pane has its own independent Timeline. +- Each pane keeps at most `commandTimeline.historyLimit` commands, and each + pane has its own independent Timeline. Commands scrolled out of the terminal's scrollback are dropped from the Timeline too, so the list never outlives the buffer it describes. diff --git a/docs/user/keyboard-shortcuts.md b/docs/user/keyboard-shortcuts.md index 312bf0b85..18f3259bc 100644 --- a/docs/user/keyboard-shortcuts.md +++ b/docs/user/keyboard-shortcuts.md @@ -24,3 +24,29 @@ while it owns pointer capture. Pane movement commands are not available in winTerm 1.1. A legacy custom `movePane` action can still be parsed safely but is disabled. + +## Command Timeline + +| Shortcut | Behavior | +| --- | --- | +| `Ctrl+Tab` | Toggle the Command Timeline for the focused pane | +| `Ctrl+T` | Next tab | +| `Ctrl+Shift+T` | Previous tab | +| `Ctrl+Alt+T` | Open new tab | + +While the Command Timeline is open: + +| Key | Behavior | +| --- | --- | +| `/` or `Tab` | Move focus to the filter box | +| `Up` / `Down` | Move the selection by one command | +| `Left` / `Right` | Select the first / last command on the current page | +| `Enter` | Load the selected command onto the input line; never runs it | +| `Space` | Scroll the terminal to that command's output | +| `Ctrl+C` | Copy the selected command text | +| `Escape` | Clear the filter, or close the Timeline if the filter is empty | + +Explicit user key bindings take precedence over all of these defaults. Keys the +Timeline consumes are not sent to the shell, and text typed into the filter box +never reaches the shell. See [Command Timeline](command-timeline.md) for the +full behavior. diff --git a/docs/user/privacy.md b/docs/user/privacy.md index 83bb83498..e2f5c246b 100644 --- a/docs/user/privacy.md +++ b/docs/user/privacy.md @@ -5,3 +5,22 @@ command text, terminal output, clipboard content, Workspace content, or general usage analytics. Visual Progress recognition is bounded, local, and in-memory; it does not upload or persist terminal content, and recognized-output replacement is off by default. + +## Command Timeline + +The [Command Timeline](command-timeline.md) keeps a per-pane list of commands +entirely in memory: + +- Command text lives only in the pane that ran it and only while that pane is + open. Nothing is written to disk; there is no history file or database. +- Command output is never cached, indexed, or searched. Output is read from the + terminal buffer only when you explicitly choose to copy it, and is not + retained afterwards. +- Filter text is never persisted and never leaves the pane. Closing the Timeline + releases it. +- Each command's stored text is capped at 4096 characters, and each pane keeps + at most `commandTimeline.historyLimit` commands (default 500, maximum 5000). +- The clipboard is written only by an explicit copy action and is never read. + Loading a command onto the input line does not use the clipboard. +- No telemetry is written, and no command, output, path, or filter text is + logged. diff --git a/scripts/winterm/package-shell-assets.ps1 b/scripts/winterm/package-shell-assets.ps1 index 803e18a94..bfe4703bf 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.3' -or +if ($version.moduleVersion -ne '1.2.4' -or $version.modulePrerelease -ne '' -or - $version.applicationVersion -ne '1.2.3' -or + $version.applicationVersion -ne '1.2.4' -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 index e320ca2da..55281d814 100644 --- a/scripts/winterm/test-command-timeline.ps1 +++ b/scripts/winterm/test-command-timeline.ps1 @@ -79,6 +79,51 @@ Assert-Contains -Content $modelSource -Values @( 'viewState.loadedCommandId.reset()' ) -Failure 'Command Timeline load, confirmation, or execution-generation semantics are incomplete.' +Assert-Contains -Content $modelHeader -Values @( + 'MaxCommandTimelineQueryLength = 256', + 'DefaultCommandTimelineHistoryLimit = 500', + 'MinCommandTimelineHistoryLimit = 50', + 'MaxCommandTimelineHistoryLimit = 5000', + 'NormalizeCommandTimelineQuery', + 'CommandTimelineQueryMatches', + 'ClampCommandTimelineHistoryLimit', + 'enum class CommandTimelineEmptyState', + 'SetHistoryLimit' +) -Failure 'Phase 4 search bounds, history limit, or degradation states are incomplete.' +Assert-Contains -Content $modelSource -Values @( + 'CommandTimelineEmptyState::NoMatchingCommands', + 'CommandTimelineEmptyState::ShellUnsupported', + 'CommandTimelineEmptyState::WaitingForShell', + '_rebuildFilter', + '_nearestPosition', + '_applyHistoryLimit' +) -Failure 'Phase 4 filtered projection or eviction semantics are incomplete.' + +# Search must be literal and case-insensitive over command text only. A regex +# engine, a fuzzy matcher, or an output field would each break the stated +# privacy and performance boundaries. Compare against code only: the model +# documents why these are excluded, and that prose must not trip the guard. +$modelCode = [regex]::Replace($modelSource, '//.*', '') + [regex]::Replace($modelHeader, '//.*', '') +foreach ($forbidden in @('std::regex', 'std::wregex', 'regex_search', 'fuzzy', 'Fuzzy')) { + if ($modelCode.Contains($forbidden)) { + throw "Command Timeline search must be literal. Found '$forbidden'." + } +} +$matchStart = $modelSource.IndexOf('bool CommandTimelineQueryMatches', [StringComparison]::Ordinal) +if ($matchStart -lt 0) { + throw 'CommandTimelineQueryMatches is missing.' +} +$matchBody = $modelSource.Substring($matchStart, $modelSource.IndexOf("`n }", $matchStart, [StringComparison]::Ordinal) - $matchStart) +if (-not $matchBody.Contains('std::search') -or -not $matchBody.Contains('towlower')) { + throw 'Command Timeline search must be a case-insensitive literal substring search.' +} + +$filterStart = $modelSource.IndexOf('void CommandTimelineNavigationModel::_rebuildFilter', [StringComparison]::Ordinal) +$filterBody = $modelSource.Substring($filterStart, $modelSource.IndexOf("`n }", $filterStart, [StringComparison]::Ordinal) - $filterStart) +if (-not $filterBody.Contains('cachedCommandText')) { + throw 'Command Timeline filtering must only read the bounded cached command text.' +} + Assert-Contains -Content $controlCoreHeader -Values @( 'CommandTimelineNavigationModel _commandTimelineNavigation', 'OpenCommandTimeline', @@ -225,6 +270,55 @@ Assert-Contains -Content $resources -Values @( 'CommandTimelineConfirmLoad' ) -Failure 'Timeline entry-action localized strings are missing.' +# Phase 4: search box, settings, and degradation states. +Assert-Contains -Content $resources -Values @( + 'CommandTimelineSearchBox.[using:Windows.UI.Xaml.Automation]AutomationProperties.Name', + 'CommandTimelineNoMatchingCommands', + 'CommandTimelineWaitingForShell' +) -Failure 'Timeline search accessibility name or empty-state strings are missing.' +Assert-Contains -Content $overlayXaml -Values @( + 'x:Name="CommandTimelineSearchBox"', + 'MaxLength="256"', + 'KeyDown="_CommandTimelineSearchKeyDown"', + 'TextChanged="_CommandTimelineSearchTextChanged"' +) -Failure 'The Timeline filter box is missing or unbounded.' +Assert-Contains -Content $termControlSource -Values @( + 'FilterCommandTimeline', + 'NormalizeCommandTimelineQuery', + '_focusCommandTimelineSearch()', + 'CommandTimelineSearchBox().Text({})', + 'CommandTimelineEmptyState::NoMatchingCommands', + 'presentation.filteredEntryCount', + '_applyCommandTimelineEnabledSetting' +) -Failure 'Timeline filtering, focus routing, cleanup, or enabled-setting handling is incomplete.' + +$settingsMacros = Read-Source 'src\cascadia\TerminalSettingsModel\MTSMSettings.h' +Assert-Contains -Content $settingsMacros -Values @( + 'X(bool, CommandTimelineEnabled, "commandTimeline.enabled", true)', + 'X(int32_t, CommandTimelineHistoryLimit, "commandTimeline.historyLimit", 500)' +) -Failure 'The public Command Timeline settings are not declared with their documented defaults.' +Assert-Contains -Content $defaults -Values @( + '"commandTimeline.enabled": true', + '"commandTimeline.historyLimit": 500' +) -Failure 'The Command Timeline defaults are missing from defaults.json.' + +$schema = Read-Source 'doc\cascadia\profiles.schema.json' +Assert-Contains -Content $schema -Values @( + '"commandTimeline.enabled"', + '"commandTimeline.historyLimit"', + '"maximum": 5000', + '"minimum": 50' +) -Failure 'The Command Timeline settings schema is missing its type, default, or range.' + +$settingsUi = Read-Source 'src\cascadia\TerminalSettingsEditor\GlobalAppearance.xaml' +Assert-Contains -Content $settingsUi -Values @( + 'x:Uid="Globals_CommandTimelineHeader"', + 'ViewModel.CommandTimelineEnabled', + 'ViewModel.CommandTimelineHistoryLimit', + 'Maximum="5000"', + 'Minimum="50"' +) -Failure 'The Settings UI does not expose the Command Timeline settings within their range.' + Assert-Contains -Content $controlTests -Values @( 'NavigationPageEdgesMoveOneInOneOut', 'NavigationWheelAccumulatesReversesAndSettles', @@ -235,8 +329,27 @@ Assert-Contains -Content $controlTests -Values @( 'ActionLateCompletionIsDetectedAfterExecutionStart', 'ActionLoadedInputIsReleasedOnEviction', 'ActionCopyResolvesStableCommandIdNotRowIndex', - 'ActionOutputRequiresLiveNativeRangeAndIsNeverCached' -) -Failure 'Deterministic Timeline navigation, action, pane isolation, cleanup, or warm-access tests are missing.' + 'ActionOutputRequiresLiveNativeRangeAndIsNeverCached', + 'SearchMatchesLiterallyAndCaseInsensitively', + 'SearchQueryTruncationIsSurrogateSafe', + 'SearchKeepsStableSelectionAcrossQueryChanges', + 'SearchSelectsNearestSurvivingMatch', + 'SearchNavigationAndWheelWalkFilteredProjection', + 'SearchNewCommandFollowsLatestOnlyWhenMatching', + 'ShellDegradationStatesAreDistinct', + 'HistoryLimitEvictsOldestFirstAndNeverResurrects', + 'HistoryLimitClampsToSupportedRange', + 'SearchCloseReleasesQueryAndProjection', + 'SearchStressAtMaximumHistoryLimit' +) -Failure 'Deterministic Timeline navigation, action, search, eviction, pane isolation, cleanup, or warm-access tests are missing.' + +$commandTimelineSettingsTests = Read-Source 'src\cascadia\UnitTests_SettingsModel\WinTermCommandTimelineTests.cpp' +Assert-Contains -Content $commandTimelineSettingsTests -Values @( + 'CommandTimelineSettingsUseStableDefaults', + 'CommandTimelineSettingsRoundTripThroughJson', + 'CommandTimelineHistoryLimitIsClampedAtRuntime', + 'CommandTimelineSettingsTolerateOutOfRangeAndOddValues' +) -Failure 'Command Timeline settings defaults, round-trip, range, and invalid-value tests are missing.' Assert-Contains -Content $settingsTests -Values @( 'CommandTimelineDefaultShortcutsAndUserOverride', 'ShortcutAction::ToggleCommandTimeline', @@ -245,4 +358,4 @@ Assert-Contains -Content $settingsTests -Values @( [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 3 source, input, action, accessibility, privacy, and lifecycle boundaries' -ForegroundColor Green +Write-Host 'PASS: Command Timeline Phase 4 source, search, settings, input, action, accessibility, privacy, and lifecycle boundaries' -ForegroundColor Green diff --git a/scripts/winterm/test-visual-progress.ps1 b/scripts/winterm/test-visual-progress.ps1 index 289176059..e0d390884 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.3' - packageVersion = '1.2.3.0' - moduleVersion = '1.2.3' + applicationVersion = '1.2.4' + packageVersion = '1.2.4.0' + moduleVersion = '1.2.4' modulePrerelease = '' channel = 'stable' - tag = 'v1.2.3' + tag = 'v1.2.4' workspaceSchemaVersion = 2 dockingModelVersion = 1 shellProtocolVersion = 1 @@ -1398,39 +1398,39 @@ try } } $shellVersion = $source.ShellVersion | ConvertFrom-Json - if ($shellVersion.applicationVersion -ne '1.2.3' -or $shellVersion.moduleVersion -ne '1.2.3' -or $shellVersion.protocolVersion -ne 1) + if ($shellVersion.applicationVersion -ne '1.2.4' -or $shellVersion.moduleVersion -ne '1.2.4' -or $shellVersion.protocolVersion -ne 1) { - throw 'Shell version metadata does not match winTerm engineering checkpoint 1.2.3 with protocol version 1.' + throw 'Shell version metadata does not match winTerm engineering checkpoint 1.2.4 with protocol version 1.' } foreach ($surface in @( - @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.2.3" }'; Description = 'About release metadata' }, - @{ Content = $source.PackageManifest; Value = 'Version="1.2.3.0"'; Description = 'MSIX package manifest' }, - @{ Content = $source.HostResource; Value = 'FILEVERSION 1,2,3,0'; Description = 'Terminal host file version' }, - @{ Content = $source.HostResource; Value = '"ProductVersion", "1.2.3\0"'; Description = 'Terminal host display version' }, - @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,2,3,0'; Description = 'Shim file version' }, - @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.2.3\0"'; Description = 'Shim display version' }, + @{ Content = $source.ReleaseMetadata; Value = 'ApplicationVersion{ L"1.2.4" }'; Description = 'About release metadata' }, + @{ Content = $source.PackageManifest; Value = 'Version="1.2.4.0"'; Description = 'MSIX package manifest' }, + @{ Content = $source.HostResource; Value = 'FILEVERSION 1,2,4,0'; Description = 'Terminal host file version' }, + @{ Content = $source.HostResource; Value = '"ProductVersion", "1.2.4\0"'; Description = 'Terminal host display version' }, + @{ Content = $source.ShimResource; Value = 'FILEVERSION 1,2,4,0'; Description = 'Shim file version' }, + @{ Content = $source.ShimResource; Value = '"ProductVersion", "1.2.4\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.3'"; Description = 'PowerShell module manifest' }, - @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.2.3'"; Description = 'PowerShell module runtime' }, + @{ Content = $source.ShellModuleManifest; Value = "ModuleVersion = '1.2.4'"; Description = 'PowerShell module manifest' }, + @{ Content = $source.ShellModule; Value = "`$script:WinTermModuleVersion = '1.2.4'"; Description = 'PowerShell module runtime' }, @{ Content = $source.PackageShellAssets; Value = "'shell\shared\version.json'"; Description = 'Canonical shell version metadata packaging' }, - @{ Content = $source.WorkspaceSerializer; Value = '"1.2.3"'; Description = 'Workspace application-version fallback' } + @{ Content = $source.WorkspaceSerializer; Value = '"1.2.4"'; Description = 'Workspace application-version fallback' } )) { Assert-Contains $surface.Content $surface.Value $surface.Description } foreach ($required in @( - "applicationVersion -eq '1.2.3'", - "packageVersion -eq '1.2.3.0'", - "moduleVersion -eq '1.2.3'", - "tag -eq 'v1.2.3'", + "applicationVersion -eq '1.2.4'", + "packageVersion -eq '1.2.4.0'", + "moduleVersion -eq '1.2.4'", + "tag -eq 'v1.2.4'", "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.3 version validation surface' + Assert-Contains $source.VerifyVersion $required 'Authoritative v1.2.4 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 25eab6786..031c0feb7 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.3' -or + if ($manifest.ModuleVersion -ne '1.2.4' -or $manifest.PrivateData.PSData.Prerelease -ne '' -or $manifest.PowerShellVersion -ne '5.1') { diff --git a/scripts/winterm/verify-branding.ps1 b/scripts/winterm/verify-branding.ps1 index 7e7847993..2f49ef1ca 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.3.0') -Message "$Path uses package version 1.2.3.0" + Test-Requirement -Condition ($null -ne $identity -and $identity.Version -eq '1.2.4.0') -Message "$Path uses package version 1.2.4.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 c2783be63..06d6865f8 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.3') 'Application version is 1.2.3' - Assert-Condition ($version.packageVersion -eq '1.2.3.0') 'Package version is 1.2.3.0' - Assert-Condition ($version.moduleVersion -eq '1.2.3') 'PowerShell module version is 1.2.3' + Assert-Condition ($version.applicationVersion -eq '1.2.4') 'Application version is 1.2.4' + Assert-Condition ($version.packageVersion -eq '1.2.4.0') 'Package version is 1.2.4.0' + Assert-Condition ($version.moduleVersion -eq '1.2.4') 'PowerShell module version is 1.2.4' 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.3') 'Engineering checkpoint tag is v1.2.3' + Assert-Condition ($version.tag -eq 'v1.2.4') 'Engineering checkpoint tag is v1.2.4' 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.3'")) 'PowerShell module runtime version matches release metadata' + Assert-Condition ((Get-Text 'shell\powershell\winTerm.Shell\winTerm.Shell.psm1').Contains("`$script:WinTermModuleVersion = '1.2.4'")) '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.3" }')) 'About metadata application version is 1.2.3' + Assert-Condition ($releaseHeader.Contains('ApplicationVersion{ L"1.2.4" }')) 'About metadata application version is 1.2.4' 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.3" }')) 'Workspace model application-version fallback is 1.2.3' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Model\WorkspaceDescriptor.h').Contains('applicationVersion{ "1.2.4" }')) 'Workspace model application-version fallback is 1.2.4' 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.3"')) 'Workspace serializer application-version fallback is 1.2.3' + Assert-Condition ((Get-Text 'src\winterm\Workspaces\Persistence\WorkspaceSerializer.cpp').Contains('"1.2.4"')) 'Workspace serializer application-version fallback is 1.2.4' $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.3' + Assert-Condition ($LASTEXITCODE -eq 0 -and $tag -eq $version.tag) 'Checked-out commit is exactly tagged v1.2.4' } 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 72cb6335e..22b9bb95d 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.3' + ModuleVersion = '1.2.4' 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 3aa544d60..c117e226f 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.3' +$script:WinTermModuleVersion = '1.2.4' $script:WinTermProtocolVersion = 1 $script:WinTermIntegrationEnabled = $false $script:WinTermPromptWrapped = $false diff --git a/shell/shared/version.json b/shell/shared/version.json index fd6e1946a..da2d0f242 100644 --- a/shell/shared/version.json +++ b/shell/shared/version.json @@ -1,6 +1,6 @@ { - "applicationVersion": "1.2.3", - "moduleVersion": "1.2.3", + "applicationVersion": "1.2.4", + "moduleVersion": "1.2.4", "modulePrerelease": "", "protocolVersion": 1 } diff --git a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest index 640a6cc99..c7a8dd902 100644 --- a/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-winTerm.appxmanifest @@ -18,7 +18,7 @@ + Version="1.2.4.0" /> winTerm diff --git a/src/cascadia/TerminalControl/ControlCore.cpp b/src/cascadia/TerminalControl/ControlCore.cpp index 1a0e34ab4..9f23f9338 100644 --- a/src/cascadia/TerminalControl/ControlCore.cpp +++ b/src/cascadia/TerminalControl/ControlCore.cpp @@ -105,6 +105,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation commandTimelineSessionId = ::Microsoft::Console::Utils::CreateGuid(); } _commandTimelineIndex = std::make_unique(commandTimelineSessionId); + // A new pane starts at the configured limit rather than the built-in + // default, so the setting applies to new and existing panes alike. + _commandTimelineIndex->SetHistoryLimit( + winTerm::CommandTimeline::ClampCommandTimelineHistoryLimit(settings.CommandTimelineHistoryLimit())); const auto commandline = settings.Commandline(); _commandTimelineIndex->SetCapabilityFallback( winTerm::CommandTimeline::InferCapabilityFallbackFromCommandline( @@ -975,6 +979,15 @@ namespace winrt::Microsoft::Terminal::Control::implementation const auto lock = _terminal->LockForWriting(); + // A settings change reaches panes that already exist, so a lowered + // history limit evicts here rather than only on the next new pane. + if (_commandTimelineIndex) + { + _commandTimelineIndex->SetHistoryLimit( + winTerm::CommandTimeline::ClampCommandTimelineHistoryLimit(_settings.CommandTimelineHistoryLimit())); + _commandTimelineActions.ReconcileLoadedInput(_commandTimelineIndex->Entries(), _commandTimelineViewState); + } + _builtinGlyphs = _settings.EnableBuiltinGlyphs(); _colorGlyphs = _settings.EnableColorGlyphs(); _cellWidth = CSSLengthPercentage::FromString(_settings.CellWidth().c_str()); @@ -1732,6 +1745,48 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelineNavigation.Close(); } + // Applies the query to this pane's bounded command-text index only. It + // never reads output and never rescans the terminal buffer. + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot ControlCore::FilterCommandTimeline( + const std::wstring_view query) + { + const auto lock = _terminal->LockForWriting(); + _ensureCommandTimelineBootstrap(); + return _commandTimelineNavigation.SetQuery( + query, + _commandTimelineIndex->Entries(), + _commandTimelineViewState, + _commandTimelineIndex->Capability()); + } + + // Pushes the current commandTimeline.historyLimit onto this pane's index. + // Lowering it evicts oldest-first immediately; raising it never resurrects + // an entry that was already dropped. + void ControlCore::ApplyCommandTimelineHistoryLimit() + { + const auto configured = winTerm::CommandTimeline::ClampCommandTimelineHistoryLimit( + _settings ? _settings.CommandTimelineHistoryLimit() : 0); + + const auto lock = _terminal->LockForWriting(); + if (!_commandTimelineIndex) + { + return; + } + + const auto revisionBefore = _commandTimelineIndex->Revision(); + _commandTimelineIndex->SetHistoryLimit(configured); + if (_commandTimelineIndex->Revision() != revisionBefore) + { + _commandTimelineActions.ReconcileLoadedInput(_commandTimelineIndex->Entries(), _commandTimelineViewState); + CommandTimelineChanged.raise(*this, nullptr); + } + } + + bool ControlCore::CommandTimelineEnabled() const + { + return _settings ? _settings.CommandTimelineEnabled() : true; + } + winTerm::CommandTimeline::CommandActionRequest ControlCore::PrepareCommandTimelineAction( const winTerm::CommandTimeline::CommandActionKind kind) { diff --git a/src/cascadia/TerminalControl/ControlCore.h b/src/cascadia/TerminalControl/ControlCore.h index b4fb58dd4..e24c5e2ee 100644 --- a/src/cascadia/TerminalControl/ControlCore.h +++ b/src/cascadia/TerminalControl/ControlCore.h @@ -188,6 +188,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation std::wstring ResolveCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request); bool JumpToCommandTimelineOutput(const winTerm::CommandTimeline::CommandActionRequest& request); bool CopyCommandTimelineText(const std::wstring& text); + winTerm::CommandTimeline::CommandTimelinePresentationSnapshot FilterCommandTimeline(std::wstring_view query); + void ApplyCommandTimelineHistoryLimit(); + bool CommandTimelineEnabled() const; hstring Title(); Windows::Foundation::IReference TabColor() noexcept; diff --git a/src/cascadia/TerminalControl/IControlSettings.idl b/src/cascadia/TerminalControl/IControlSettings.idl index 19f0d70ec..c9b7e9ff9 100644 --- a/src/cascadia/TerminalControl/IControlSettings.idl +++ b/src/cascadia/TerminalControl/IControlSettings.idl @@ -57,6 +57,8 @@ namespace Microsoft.Terminal.Control Boolean FocusFollowMouse { get; }; Boolean ScrollToZoom { get; }; Boolean ScrollToChangeOpacity { get; }; + Boolean CommandTimelineEnabled { get; }; + Int32 CommandTimelineHistoryLimit { get; }; String Commandline { get; }; String StartingDirectory { get; }; diff --git a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw index 141414d3d..de4b1842e 100644 --- a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw @@ -394,4 +394,16 @@ Press Enter again to load this command + + Filter commands + + + Filter commands + + + No matching commands + + + Waiting for shell integration + diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp index 53ae239e3..9adc1af22 100644 --- a/src/cascadia/TerminalControl/TermControl.cpp +++ b/src/cascadia/TerminalControl/TermControl.cpp @@ -900,6 +900,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation // Update our control settings _ApplyUISettings(); + + // Reaches panes that already exist, so toggling commandTimeline.enabled + // hides the handle and closes an open overlay immediately. + _applyCommandTimelineEnabledSetting(); } // Method Description: @@ -2268,6 +2272,8 @@ namespace winrt::Microsoft::Terminal::Control::implementation case VK_ESCAPE: case VK_RETURN: case VK_SPACE: + case VK_TAB: + case VK_OEM_2: break; default: return false; @@ -2291,15 +2297,26 @@ namespace winrt::Microsoft::Terminal::Control::implementation switch (vkey) { + case VK_TAB: + case VK_OEM_2: + // Slash and Tab move focus to the filter box. Both are consumed + // here so neither reaches the PTY. + _focusCommandTimelineSearch(); + return true; case VK_ESCAPE: - // A pending load confirmation is cancelled before the overlay - // itself closes, so Escape never discards more than one step. + // A pending load confirmation is cancelled first, then a non-empty + // query, and only then does the overlay close. if (_commandTimelinePendingLoad.has_value()) { _clearCommandTimelinePendingLoad(); _setCommandTimelineStatus({}); return true; } + if (!CommandTimelineSearchBox().Text().empty()) + { + CommandTimelineSearchBox().Text({}); + return true; + } _closeCommandTimeline(true); return true; case VK_RETURN: @@ -2498,6 +2515,126 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelinePendingLoad.reset(); } + void TermControl::_focusCommandTimelineSearch() + { + if (_commandTimelineOpen && !_IsClosing()) + { + CommandTimelineSearchBox().Focus(FocusState::Programmatic); + } + } + + // Filtering runs entirely over this pane's bounded command-text index. The + // query itself is UI-only state and is never persisted. + void TermControl::_CommandTimelineSearchTextChanged(const IInspectable& /*sender*/, + const Controls::TextChangedEventArgs& /*args*/) + { + if (!_commandTimelineOpen || _IsClosing()) + { + return; + } + + try + { + // Changing the query invalidates a confirmation waiting on the + // previously selected command. + _clearCommandTimelinePendingLoad(); + _setCommandTimelineStatus({}); + + const auto box = CommandTimelineSearchBox(); + const std::wstring_view raw{ box.Text() }; + const auto normalized = winTerm::CommandTimeline::NormalizeCommandTimelineQuery(raw); + if (normalized.size() != raw.size()) + { + // Surrogate-safe truncation may shorten the text; reflect that + // back into the box without re-entering this handler's work. + _updatingCommandTimelineSelection = true; + box.Text(winrt::hstring{ normalized }); + box.SelectionStart(gsl::narrow_cast(normalized.size())); + _updatingCommandTimelineSelection = false; + } + + const auto presentation = get_self(_core)->FilterCommandTimeline(normalized); + _renderCommandTimeline(presentation); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + _closeCommandTimeline(true); + } + } + + // Keys handled while the filter box owns focus. Everything not claimed here + // stays with the text box, so ordinary caret editing, selection, and IME + // composition behave normally and nothing reaches the PTY. + void TermControl::_CommandTimelineSearchKeyDown(const IInspectable& /*sender*/, + const Input::KeyRoutedEventArgs& args) + { + if (!_commandTimelineOpen || _IsClosing()) + { + return; + } + + switch (args.Key()) + { + case Windows::System::VirtualKey::Up: + get_self(_core)->NavigateCommandTimeline(winTerm::CommandTimeline::NavigationAction::Previous); + _refreshCommandTimeline(); + args.Handled(true); + return; + case Windows::System::VirtualKey::Down: + get_self(_core)->NavigateCommandTimeline(winTerm::CommandTimeline::NavigationAction::Next); + _refreshCommandTimeline(); + args.Handled(true); + return; + case Windows::System::VirtualKey::Enter: + _loadCommandTimelineSelection(); + args.Handled(true); + return; + case Windows::System::VirtualKey::Escape: + // Escape clears a non-empty query first and only closes the overlay + // once the query is already empty. + if (!CommandTimelineSearchBox().Text().empty()) + { + CommandTimelineSearchBox().Text({}); + } + else + { + _closeCommandTimeline(true); + } + args.Handled(true); + return; + default: + // Left/Right and every printable key stay with the text box. + return; + } + } + + // Honors commandTimeline.enabled. Disabling hides the handle and closes an + // overlay that is already open. + void TermControl::_applyCommandTimelineEnabledSetting() + { + if (_IsClosing()) + { + return; + } + + auto enabled = true; + try + { + enabled = get_self(_core)->CommandTimelineEnabled(); + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + } + + CommandTimelineHandle().Visibility(enabled ? Visibility::Visible : Visibility::Collapsed); + if (!enabled && _commandTimelineOpen) + { + _closeCommandTimeline(true); + } + } + // Builds the per-entry context menu. Every action resolves through the // model's stable CommandId rather than the XAML row, so a list rebuild // between the right-click and the invocation cannot retarget the action. @@ -2734,11 +2871,24 @@ namespace winrt::Microsoft::Terminal::Control::implementation if (presentation.visibleEntries.empty()) { + using winTerm::CommandTimeline::CommandTimelineEmptyState; list.Visibility(Visibility::Collapsed); - CommandTimelineEmptyText().Text( - presentation.capability == winTerm::CommandTimeline::ShellIntegrationCapability::Limited ? - RS_(L"CommandTimelineUnavailable") : - RS_(L"CommandTimelineNoCommands")); + // Each state is distinct: an unsupported shell is never reported as + // simply having run no commands. + const auto emptyText = [&]() { + switch (presentation.emptyState) + { + case CommandTimelineEmptyState::NoMatchingCommands: + return RS_(L"CommandTimelineNoMatchingCommands"); + case CommandTimelineEmptyState::ShellUnsupported: + return RS_(L"CommandTimelineUnavailable"); + case CommandTimelineEmptyState::NoCommands: + return RS_(L"CommandTimelineNoCommands"); + default: + return RS_(L"CommandTimelineWaitingForShell"); + } + }(); + CommandTimelineEmptyText().Text(emptyText); CommandTimelineEmptyText().Visibility(Visibility::Visible); _updatingCommandTimelineSelection = false; return; @@ -2797,9 +2947,12 @@ namespace winrt::Microsoft::Terminal::Control::implementation Windows::UI::Xaml::Automation::AutomationProperties::SetPositionInSet( item, gsl::narrow_cast(presentation.firstVisibleIndex + slot + 1)); + // Position and set size describe the filtered result, so assistive + // technology announces "3 of 7 matches", not a position within the + // unfiltered history. Windows::UI::Xaml::Automation::AutomationProperties::SetSizeOfSet( item, - gsl::narrow_cast(presentation.totalEntryCount)); + gsl::narrow_cast(presentation.filteredEntryCount)); item.PointerEntered([weakThis = get_weak(), slot](const auto&, const auto&) { if (auto control{ weakThis.get() }; @@ -2876,6 +3029,9 @@ namespace winrt::Microsoft::Terminal::Control::implementation _commandTimelineOpen = false; _clearCommandTimelinePendingLoad(); _updatingCommandTimelineSelection = true; + // The query, the filtered projection, and every materialized row are + // released together; nothing about a search survives a close. + CommandTimelineSearchBox().Text({}); CommandTimelineList().Items().Clear(); CommandTimelineList().SelectedIndex(-1); _updatingCommandTimelineSelection = false; @@ -3359,6 +3515,21 @@ namespace winrt::Microsoft::Terminal::Control::implementation return true; } + // With commandTimeline.enabled off, the shortcut must not open the + // overlay at all. + try + { + if (!get_self(_core)->CommandTimelineEnabled()) + { + return false; + } + } + catch (...) + { + LOG_CAUGHT_EXCEPTION(); + return false; + } + try { _commandTimelineOpen = true; diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h index e1a95de00..6109eb63d 100644 --- a/src/cascadia/TerminalControl/TermControl.h +++ b/src/cascadia/TerminalControl/TermControl.h @@ -403,6 +403,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation bool _copyCommandTimelineOutput(); void _setCommandTimelineStatus(const winrt::hstring& status); void _clearCommandTimelinePendingLoad(); + void _CommandTimelineSearchTextChanged(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Controls::TextChangedEventArgs& e); + void _CommandTimelineSearchKeyDown(const Windows::Foundation::IInspectable& sender, const Windows::UI::Xaml::Input::KeyRoutedEventArgs& e); + void _focusCommandTimelineSearch(); + void _applyCommandTimelineEnabledSetting(); bool _tryHandleCommandTimelineWheel(const Windows::Foundation::Point& position, int delta); bool _isPointOverCommandTimeline(const Windows::Foundation::Point& position) noexcept; size_t _commandTimelineVisibleCapacity() noexcept; diff --git a/src/cascadia/TerminalControl/TermControl.xaml b/src/cascadia/TerminalControl/TermControl.xaml index d13148a86..25a498913 100644 --- a/src/cascadia/TerminalControl/TermControl.xaml +++ b/src/cascadia/TerminalControl/TermControl.xaml @@ -1383,10 +1383,18 @@ - + + + + + + + + + + + + + + diff --git a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h index 723917eb9..ab0118ac0 100644 --- a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h +++ b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.h @@ -51,6 +51,8 @@ namespace winrt::Microsoft::Terminal::Settings::Editor::implementation PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, VisualProgressEnabled); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, VisualProgressRecognizeCliProgress); PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, VisualProgressReplaceRecognizedOutput); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, CommandTimelineEnabled); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(_GlobalSettings, CommandTimelineHistoryLimit); private: Model::GlobalAppSettings _GlobalSettings; diff --git a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl index 77358e818..0623884f4 100644 --- a/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl +++ b/src/cascadia/TerminalSettingsEditor/GlobalAppearanceViewModel.idl @@ -49,5 +49,7 @@ namespace Microsoft.Terminal.Settings.Editor PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, VisualProgressEnabled); PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, VisualProgressRecognizeCliProgress); PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, VisualProgressReplaceRecognizedOutput); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Boolean, CommandTimelineEnabled); + PERMANENT_OBSERVABLE_PROJECTED_SETTING(Int32, CommandTimelineHistoryLimit); } } diff --git a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw index e0439805e..00f52e940 100644 --- a/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw +++ b/src/cascadia/TerminalSettingsEditor/Resources/en-US/Resources.resw @@ -2873,6 +2873,21 @@ Reset pane resizing to defaults + + Command timeline + + + Show command timeline + + + When enabled, each pane shows a handle that opens a list of the commands that pane has run. Requires shell integration. + + + Commands remembered per pane + + + How many commands each pane keeps in memory, between 50 and 5000. Lowering this discards the oldest commands immediately. Command history is never written to disk. + Visual progress diff --git a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl index 1aeff7b38..8dd5d88a5 100644 --- a/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl +++ b/src/cascadia/TerminalSettingsModel/GlobalAppSettings.idl @@ -118,6 +118,8 @@ namespace Microsoft.Terminal.Settings.Model INHERITABLE_SETTING(WindowingMode, WindowingBehavior); INHERITABLE_SETTING(Boolean, TrimBlockSelection); INHERITABLE_SETTING(Boolean, DetectURLs); + INHERITABLE_SETTING(Boolean, CommandTimelineEnabled); + INHERITABLE_SETTING(Int32, CommandTimelineHistoryLimit); INHERITABLE_SETTING(Boolean, MinimizeToNotificationArea); INHERITABLE_SETTING(Boolean, AlwaysShowNotificationIcon); INHERITABLE_SETTING(IVector, DisabledProfileSources); diff --git a/src/cascadia/TerminalSettingsModel/MTSMSettings.h b/src/cascadia/TerminalSettingsModel/MTSMSettings.h index 4f8fc6bd9..984d9d518 100644 --- a/src/cascadia/TerminalSettingsModel/MTSMSettings.h +++ b/src/cascadia/TerminalSettingsModel/MTSMSettings.h @@ -41,6 +41,8 @@ Author(s): X(bool, UseBackgroundImageForWindow, "experimental.useBackgroundImageForWindow", false) \ X(bool, TrimBlockSelection, "trimBlockSelection", true) \ X(bool, DetectURLs, "experimental.detectURLs", true) \ + X(bool, CommandTimelineEnabled, "commandTimeline.enabled", true) \ + X(int32_t, CommandTimelineHistoryLimit, "commandTimeline.historyLimit", 500) \ X(bool, AlwaysShowTabs, "alwaysShowTabs", true) \ X(Model::NewTabPosition, NewTabPosition, "newTabPosition", Model::NewTabPosition::AfterLastTab) \ X(bool, ShowTitleInTitlebar, "showTerminalTitleInTitlebar", true) \ diff --git a/src/cascadia/TerminalSettingsModel/defaults.json b/src/cascadia/TerminalSettingsModel/defaults.json index be57538c9..fc62f990c 100644 --- a/src/cascadia/TerminalSettingsModel/defaults.json +++ b/src/cascadia/TerminalSettingsModel/defaults.json @@ -32,6 +32,10 @@ "focusFollowMouse": false, "minimizeToNotificationArea": false, "alwaysShowNotificationIcon": false, + + // Command timeline + "commandTimeline.enabled": true, + "commandTimeline.historyLimit": 500, "profiles": [ diff --git a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp index 7ad2a0447..3f662afec 100644 --- a/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp +++ b/src/cascadia/UnitTests_Control/CommandTimelineTests.cpp @@ -49,6 +49,18 @@ namespace ControlUnitTests TEST_METHOD(ActionLoadedInputIsReleasedOnEviction); TEST_METHOD(ActionCopyResolvesStableCommandIdNotRowIndex); TEST_METHOD(ActionOutputRequiresLiveNativeRangeAndIsNeverCached); + TEST_METHOD(SearchEmptyQueryProjectsEveryCommand); + TEST_METHOD(SearchMatchesLiterallyAndCaseInsensitively); + TEST_METHOD(SearchQueryTruncationIsSurrogateSafe); + TEST_METHOD(SearchKeepsStableSelectionAcrossQueryChanges); + TEST_METHOD(SearchSelectsNearestSurvivingMatch); + TEST_METHOD(SearchNavigationAndWheelWalkFilteredProjection); + TEST_METHOD(SearchNewCommandFollowsLatestOnlyWhenMatching); + TEST_METHOD(ShellDegradationStatesAreDistinct); + TEST_METHOD(HistoryLimitEvictsOldestFirstAndNeverResurrects); + TEST_METHOD(HistoryLimitClampsToSupportedRange); + TEST_METHOD(SearchCloseReleasesQueryAndProjection); + TEST_METHOD(SearchStressAtMaximumHistoryLimit); TEST_CLASS_SETUP(ModuleSetup) { @@ -934,4 +946,386 @@ namespace ControlUnitTests const auto pendingOutput = actions.Prepare(CommandActionKind::CopyOutput, pending.Entries(), pendingView, CommandLoadPolicy{}); VERIFY_ARE_EQUAL(static_cast(CommandActionStatus::OutputUnavailable), static_cast(pendingOutput.status)); } + + void CommandTimelineTests::SearchEmptyQueryProjectsEveryCommand() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"alpha", 0); + Execute(index, 2, revision, L"beta", 0); + Execute(index, 3, revision, L"gamma", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + const auto opened = navigation.Open(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(size_t{ 3 }, opened.filteredEntryCount); + VERIFY_IS_FALSE(opened.filtered); + + const auto cleared = navigation.SetQuery(L"", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 3 }, cleared.filteredEntryCount); + VERIFY_ARE_EQUAL(size_t{ 3 }, cleared.visibleEntries.size()); + VERIFY_IS_FALSE(cleared.filtered); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::None), static_cast(cleared.emptyState)); + } + + void CommandTimelineTests::SearchMatchesLiterallyAndCaseInsensitively() + { + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"Git Status", L"git")); + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"git status", L"STATUS")); + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"aaaa", L"aaa")); + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"anything", L"")); + VERIFY_IS_FALSE(CommandTimelineQueryMatches(L"git", L"git status")); + // Literal matching only: regex and glob metacharacters are just text. + VERIFY_IS_FALSE(CommandTimelineQueryMatches(L"git status", L"g.*s")); + VERIFY_IS_FALSE(CommandTimelineQueryMatches(L"git status", L"gs")); + + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"GIT status", 0); + Execute(index, 2, revision, L"cargo build", 0); + Execute(index, 3, revision, L"git log", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + const auto filtered = navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + + VERIFY_ARE_EQUAL(size_t{ 2 }, filtered.filteredEntryCount); + VERIFY_IS_TRUE(filtered.filtered); + VERIFY_ARE_EQUAL(size_t{ 3 }, filtered.totalEntryCount); + VERIFY_ARE_EQUAL(std::wstring{ L"GIT status" }, filtered.visibleEntries[0].commandText); + VERIFY_ARE_EQUAL(std::wstring{ L"git log" }, filtered.visibleEntries[1].commandText); + } + + void CommandTimelineTests::SearchQueryTruncationIsSurrogateSafe() + { + // Exactly at the boundary nothing is dropped. + const std::wstring atLimit(MaxCommandTimelineQueryLength, L'x'); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength, NormalizeCommandTimelineQuery(atLimit).size()); + + const std::wstring overLimit(MaxCommandTimelineQueryLength + 50, L'x'); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength, NormalizeCommandTimelineQuery(overLimit).size()); + + // A surrogate pair straddling the boundary must not be split. U+1F600 + // is encoded as the pair D83D DE00. + std::wstring straddling(MaxCommandTimelineQueryLength - 1, L'x'); + straddling.push_back(L'\xD83D'); + straddling.push_back(L'\xDE00'); + const auto truncated = NormalizeCommandTimelineQuery(straddling); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength - 1, truncated.size()); + VERIFY_IS_FALSE(truncated.back() >= 0xD800 && truncated.back() <= 0xDBFF); + + // A pair that ends before the boundary is preserved whole. + std::wstring safe(MaxCommandTimelineQueryLength - 2, L'y'); + safe.push_back(L'\xD83D'); + safe.push_back(L'\xDE00'); + safe.append(10, L'z'); + const auto kept = NormalizeCommandTimelineQuery(safe); + VERIFY_ARE_EQUAL(MaxCommandTimelineQueryLength, kept.size()); + VERIFY_ARE_EQUAL(L'\xDE00', kept[MaxCommandTimelineQueryLength - 1]); + + // Non-ASCII text still matches literally. + VERIFY_IS_TRUE(CommandTimelineQueryMatches(L"echo \x4F60\x597D", L"\x4F60\x597D")); + } + + void CommandTimelineTests::SearchKeepsStableSelectionAcrossQueryChanges() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + Execute(index, 3, revision, L"git log", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + navigation.SelectVisibleEntry(0, index.Entries(), viewState, index.Capability()); + const auto selected = *viewState.selectedCommandId; + VERIFY_ARE_EQUAL(uint64_t{ 1 }, selected.sequence); + + // The selected command still matches, so it stays selected. + navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(selected, *viewState.selectedCommandId); + + // Clearing the query must not move the selection either. + navigation.SetQuery(L"", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(selected, *viewState.selectedCommandId); + } + + void CommandTimelineTests::SearchSelectsNearestSurvivingMatch() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + Execute(index, 3, revision, L"git log", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + navigation.SelectVisibleEntry(1, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, viewState.selectedCommandId->sequence); + + // "cargo build" no longer matches, so the nearest surviving match takes + // the selection rather than dropping to the bottom. + const auto filtered = navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 2 }, filtered.filteredEntryCount); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + + // A query with no results leaves the selected command untouched. + const auto none = navigation.SetQuery(L"zzz", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 0 }, none.filteredEntryCount); + VERIFY_IS_TRUE(none.visibleEntries.empty()); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::NoMatchingCommands), + static_cast(none.emptyState)); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::SearchNavigationAndWheelWalkFilteredProjection() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + for (uint64_t mark = 1; mark <= 10; ++mark) + { + // Odd marks match the query, even marks do not. + Execute(index, mark, revision, mark % 2 ? L"git command" : L"other command", 0); + } + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 3); + const auto filtered = navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 5 }, filtered.filteredEntryCount); + VERIFY_ARE_EQUAL(uint64_t{ 9 }, viewState.selectedCommandId->sequence); + + // Up moves to the previous *match*, skipping the non-matching command. + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 7 }, viewState.selectedCommandId->sequence); + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 5 }, viewState.selectedCommandId->sequence); + navigation.Navigate(NavigationAction::Next, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 7 }, viewState.selectedCommandId->sequence); + + // Page edges stay inside the filtered viewport. + const auto pageFirst = navigation.Navigate(NavigationAction::PageFirst, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 0 }, pageFirst.selectedVisualSlot); + const auto pageLast = navigation.Navigate(NavigationAction::PageLast, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 2 }, pageLast.selectedVisualSlot); + + // Wheel accumulation also walks matches only. A partial delta moves + // nothing; a full notch moves exactly one match. + const auto beforeWheel = viewState.selectedCommandId->sequence; + navigation.ApplyWheelDelta(40, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(beforeWheel, viewState.selectedCommandId->sequence); + navigation.ApplyWheelDelta(80, index.Entries(), viewState, index.Capability()); + VERIFY_ARE_NOT_EQUAL(beforeWheel, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(uint64_t{ 1 }, viewState.selectedCommandId->sequence % 2); + } + + void CommandTimelineTests::SearchNewCommandFollowsLatestOnlyWhenMatching() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git one", 0); + Execute(index, 2, revision, L"git two", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 10); + navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(uint64_t{ 2 }, viewState.selectedCommandId->sequence); + + // Following latest: a new matching command becomes the selection. + Execute(index, 3, revision, L"git three", 0); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + + // A new command that does not match must not change the selection. + Execute(index, 4, revision, L"unrelated", 0); + const auto afterNonMatching = navigation.Reconcile(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(uint64_t{ 3 }, viewState.selectedCommandId->sequence); + VERIFY_ARE_EQUAL(size_t{ 3 }, afterNonMatching.filteredEntryCount); + + // Browsing older history: a new matching command must not yank the + // selection back to the bottom. + navigation.Navigate(NavigationAction::Previous, index.Entries(), viewState, index.Capability()); + const auto browsing = viewState.selectedCommandId->sequence; + VERIFY_ARE_EQUAL(uint64_t{ 2 }, browsing); + Execute(index, 5, revision, L"git five", 0); + navigation.Reconcile(index.Entries(), viewState, index.Capability(), 10); + VERIFY_ARE_EQUAL(browsing, viewState.selectedCommandId->sequence); + } + + void CommandTimelineTests::ShellDegradationStatesAreDistinct() + { + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + + // Capability not yet determined and nothing recorded. + const auto waiting = navigation.Open({}, viewState, ShellIntegrationCapability::Unknown, 5); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::WaitingForShell), + static_cast(waiting.emptyState)); + + // Shell cannot report complete boundaries. + CommandTimelineNavigationModel limitedNavigation; + CommandTimelineViewState limitedView; + const auto unsupported = limitedNavigation.Open({}, limitedView, ShellIntegrationCapability::Limited, 5); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::ShellUnsupported), + static_cast(unsupported.emptyState)); + + // Shell integration works but no command has run yet. + CommandTimelineNavigationModel fullNavigation; + CommandTimelineViewState fullView; + const auto noCommands = fullNavigation.Open({}, fullView, ShellIntegrationCapability::Full, 5); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::NoCommands), + static_cast(noCommands.emptyState)); + + // Commands exist but the filter excludes all of them. + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"present", 0); + CommandTimelineNavigationModel filteredNavigation; + CommandTimelineViewState filteredView; + filteredNavigation.Open(index.Entries(), filteredView, index.Capability(), 5); + const auto noMatches = filteredNavigation.SetQuery(L"absent", index.Entries(), filteredView, index.Capability()); + VERIFY_ARE_EQUAL(static_cast(CommandTimelineEmptyState::NoMatchingCommands), + static_cast(noMatches.emptyState)); + } + + void CommandTimelineTests::HistoryLimitEvictsOldestFirstAndNeverResurrects() + { + CommandTimelineIndex index{ PaneOne }; + index.SetHistoryLimit(MinCommandTimelineHistoryLimit); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, index.HistoryLimit()); + + uint64_t revision = 0; + for (uint64_t mark = 1; mark <= 60; ++mark) + { + Execute(index, mark, revision, L"command " + std::to_wstring(mark), 0); + } + + // Bounded at the limit, oldest dropped first, newest retained. + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, index.Entries().size()); + VERIFY_ARE_EQUAL(uint64_t{ 11 }, index.Entries().front().id.sequence); + VERIFY_ARE_EQUAL(uint64_t{ 60 }, index.Entries().back().id.sequence); + + // Lowering the limit evicts immediately. + CommandTimelineIndex lowered{ PaneTwo }; + uint64_t loweredRevision = 0; + for (uint64_t mark = 1; mark <= 120; ++mark) + { + Execute(lowered, mark, loweredRevision, L"cmd", 0); + } + VERIFY_ARE_EQUAL(size_t{ 120 }, lowered.Entries().size()); + lowered.SetHistoryLimit(MinCommandTimelineHistoryLimit); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, lowered.Entries().size()); + VERIFY_ARE_EQUAL(uint64_t{ 71 }, lowered.Entries().front().id.sequence); + + // Raising the limit must not resurrect anything, and sequence numbers + // are never reissued. + lowered.SetHistoryLimit(1000); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, lowered.Entries().size()); + VERIFY_ARE_EQUAL(uint64_t{ 71 }, lowered.Entries().front().id.sequence); + VERIFY_ARE_EQUAL(uint64_t{ 121 }, lowered.NextSequence()); + Execute(lowered, 500, loweredRevision, L"after raise", 0); + VERIFY_ARE_EQUAL(uint64_t{ 121 }, lowered.Entries().back().id.sequence); + } + + void CommandTimelineTests::HistoryLimitClampsToSupportedRange() + { + VERIFY_ARE_EQUAL(DefaultCommandTimelineHistoryLimit, size_t{ 500 }); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(0)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(-1)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(49)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(50)); + VERIFY_ARE_EQUAL(size_t{ 500 }, ClampCommandTimelineHistoryLimit(500)); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(5000)); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, ClampCommandTimelineHistoryLimit(999999)); + + // An out-of-range configured value is clamped rather than rejected. + CommandTimelineIndex index{ PaneOne }; + index.SetHistoryLimit(ClampCommandTimelineHistoryLimit(1)); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, index.HistoryLimit()); + index.SetHistoryLimit(ClampCommandTimelineHistoryLimit(100000)); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, index.HistoryLimit()); + } + + void CommandTimelineTests::SearchCloseReleasesQueryAndProjection() + { + CommandTimelineIndex index{ PaneOne }; + uint64_t revision = 0; + Execute(index, 1, revision, L"git status", 0); + Execute(index, 2, revision, L"cargo build", 0); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + navigation.Open(index.Entries(), viewState, index.Capability(), 5); + navigation.SetQuery(L"git", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(std::wstring{ L"git" }, navigation.Query()); + VERIFY_ARE_EQUAL(size_t{ 1 }, navigation.FilteredCount()); + + navigation.Close(); + VERIFY_IS_TRUE(navigation.Query().empty()); + VERIFY_ARE_EQUAL(size_t{ 0 }, navigation.FilteredCount()); + VERIFY_IS_FALSE(navigation.IsOpen()); + VERIFY_IS_FALSE(navigation.WheelSettlePending()); + VERIFY_ARE_EQUAL(0, navigation.WheelDeltaRemainder()); + + // Repeated close stays safe. + navigation.Close(); + VERIFY_IS_FALSE(navigation.IsOpen()); + + // Reopening starts unfiltered: a query is never persisted. + const auto reopened = navigation.Open(index.Entries(), viewState, index.Capability(), 5); + VERIFY_IS_TRUE(navigation.Query().empty()); + VERIFY_IS_FALSE(reopened.filtered); + VERIFY_ARE_EQUAL(size_t{ 2 }, reopened.filteredEntryCount); + } + + void CommandTimelineTests::SearchStressAtMaximumHistoryLimit() + { + CommandTimelineIndex index{ PaneOne }; + index.SetHistoryLimit(MaxCommandTimelineHistoryLimit); + + uint64_t revision = 0; + for (uint64_t mark = 1; mark <= MaxCommandTimelineHistoryLimit; ++mark) + { + Execute(index, mark, revision, L"command " + std::to_wstring(mark), 0); + } + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, index.Entries().size()); + + CommandTimelineNavigationModel navigation; + CommandTimelineViewState viewState; + const auto capacity = size_t{ 20 }; + navigation.Open(index.Entries(), viewState, index.Capability(), capacity); + + // Worst case: a query that matches every entry. Only the visible rows + // are ever materialized. + const auto broad = navigation.SetQuery(L"command", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, broad.filteredEntryCount); + VERIFY_ARE_EQUAL(capacity, broad.visibleEntries.size()); + + // Repeated filtering must stay bounded and must not accumulate. + for (int pass = 0; pass < 25; ++pass) + { + const auto narrow = navigation.SetQuery(L"command 4242", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 1 }, narrow.filteredEntryCount); + VERIFY_ARE_EQUAL(size_t{ 1 }, narrow.visibleEntries.size()); + + const auto missing = navigation.SetQuery(L"no such command", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(size_t{ 0 }, missing.filteredEntryCount); + VERIFY_IS_TRUE(missing.visibleEntries.empty()); + + const auto all = navigation.SetQuery(L"", index.Entries(), viewState, index.Capability()); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, all.filteredEntryCount); + VERIFY_ARE_EQUAL(capacity, all.visibleEntries.size()); + } + + // Command text stays bounded and no output is ever cached. + VERIFY_IS_TRUE(index.CachedCommandTextCharacters() <= + MaxCommandTimelineHistoryLimit * DefaultMaxCachedCommandText); + + navigation.Close(); + VERIFY_ARE_EQUAL(size_t{ 0 }, navigation.FilteredCount()); + } } diff --git a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj index dd3b87a37..6e811a283 100644 --- a/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj +++ b/src/cascadia/UnitTests_SettingsModel/SettingsModel.UnitTests.vcxproj @@ -50,9 +50,11 @@ + + Create diff --git a/src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp b/src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp new file mode 100644 index 000000000..39acf572c --- /dev/null +++ b/src/cascadia/UnitTests_SettingsModel/WinTermCommandTimelineTests.cpp @@ -0,0 +1,132 @@ +// Copyright (c) winTerm contributors. +// Licensed under the MIT license. + +#include "pch.h" + +#include "../TerminalSettingsModel/GlobalAppSettings.h" +#include "../../winterm/CommandTimeline/CommandTimelineModel.h" + +using namespace WEX::TestExecution; +using namespace winTerm::CommandTimeline; +using namespace winrt::Microsoft::Terminal::Settings::Model; + +namespace SettingsModelUnitTests +{ + class WinTermCommandTimelineTests + { + TEST_CLASS(WinTermCommandTimelineTests); + + TEST_METHOD(CommandTimelineSettingsUseStableDefaults); + TEST_METHOD(CommandTimelineSettingsRoundTripThroughJson); + TEST_METHOD(CommandTimelineHistoryLimitIsClampedAtRuntime); + TEST_METHOD(CommandTimelineSettingsTolerateOutOfRangeAndOddValues); + + private: + static winrt::com_ptr FromJson(const Json::Value& json) + { + return implementation::GlobalAppSettings::FromJson(json); + } + }; + + void WinTermCommandTimelineTests::CommandTimelineSettingsUseStableDefaults() + { + Json::Value missing{ Json::objectValue }; + const auto defaults = FromJson(missing); + + VERIFY_IS_TRUE(defaults->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(500, defaults->CommandTimelineHistoryLimit()); + + // An absent setting must not be written back out, so an existing + // settings file needs no migration. + VERIFY_IS_FALSE(defaults->ToJson().isMember("commandTimeline.enabled")); + VERIFY_IS_FALSE(defaults->ToJson().isMember("commandTimeline.historyLimit")); + + // The documented default matches the model's own default. + VERIFY_ARE_EQUAL(DefaultCommandTimelineHistoryLimit, + static_cast(defaults->CommandTimelineHistoryLimit())); + } + + void WinTermCommandTimelineTests::CommandTimelineSettingsRoundTripThroughJson() + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.enabled"] = false; + json["commandTimeline.historyLimit"] = 1200; + + const auto settings = FromJson(json); + VERIFY_IS_FALSE(settings->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(1200, settings->CommandTimelineHistoryLimit()); + + const auto serialized = settings->ToJson(); + VERIFY_IS_FALSE(serialized["commandTimeline.enabled"].asBool()); + VERIFY_ARE_EQUAL(1200, serialized["commandTimeline.historyLimit"].asInt()); + + // Reparsing the serialized form reproduces the same values. + const auto reparsed = FromJson(serialized); + VERIFY_IS_FALSE(reparsed->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(1200, reparsed->CommandTimelineHistoryLimit()); + + Json::Value enabled{ Json::objectValue }; + enabled["commandTimeline.enabled"] = true; + enabled["commandTimeline.historyLimit"] = 50; + const auto minimum = FromJson(enabled); + VERIFY_IS_TRUE(minimum->CommandTimelineEnabled()); + VERIFY_ARE_EQUAL(50, minimum->CommandTimelineHistoryLimit()); + } + + void WinTermCommandTimelineTests::CommandTimelineHistoryLimitIsClampedAtRuntime() + { + // Whatever the settings file says, the value the runtime uses is always + // inside the supported range. + for (const auto configured : { -5000, -1, 0, 1, 49 }) + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.historyLimit"] = configured; + const auto settings = FromJson(json); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + } + + for (const auto configured : { 5001, 100000 }) + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.historyLimit"] = configured; + const auto settings = FromJson(json); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + } + + for (const auto configured : { 50, 500, 2500, 5000 }) + { + Json::Value json{ Json::objectValue }; + json["commandTimeline.historyLimit"] = configured; + const auto settings = FromJson(json); + VERIFY_ARE_EQUAL(static_cast(configured), + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + } + } + + void WinTermCommandTimelineTests::CommandTimelineSettingsTolerateOutOfRangeAndOddValues() + { + // An out-of-range value is accepted by the parser and clamped by the + // runtime rather than failing the whole settings load. + Json::Value extreme{ Json::objectValue }; + extreme["commandTimeline.historyLimit"] = 999999; + const auto settings = FromJson(extreme); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, + ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + + // A pane honors the clamped value: entries stay bounded even when the + // configured limit was nonsense. + CommandTimelineIndex index{ winrt::guid{} }; + index.SetHistoryLimit(ClampCommandTimelineHistoryLimit(settings->CommandTimelineHistoryLimit())); + VERIFY_ARE_EQUAL(MaxCommandTimelineHistoryLimit, index.HistoryLimit()); + + Json::Value tooSmall{ Json::objectValue }; + tooSmall["commandTimeline.historyLimit"] = 1; + // Note: `small` is a Windows SDK macro, so this cannot be named that. + const auto belowRange = FromJson(tooSmall); + CommandTimelineIndex bounded{ winrt::guid{} }; + bounded.SetHistoryLimit(ClampCommandTimelineHistoryLimit(belowRange->CommandTimelineHistoryLimit())); + VERIFY_ARE_EQUAL(MinCommandTimelineHistoryLimit, bounded.HistoryLimit()); + } +} diff --git a/src/cascadia/WindowsTerminal/WindowsTerminal.rc b/src/cascadia/WindowsTerminal/WindowsTerminal.rc index bdb257cf0..0e87afc96 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,3,0 - PRODUCTVERSION 1,2,3,0 + FILEVERSION 1,2,4,0 + PRODUCTVERSION 1,2,4,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.3.0\0" + VALUE "FileVersion", "1.2.4.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.3\0" + VALUE "ProductVersion", "1.2.4\0" END END BLOCK "VarFileInfo" diff --git a/src/cascadia/inc/ControlProperties.h b/src/cascadia/inc/ControlProperties.h index 91ae71178..c9bab8cac 100644 --- a/src/cascadia/inc/ControlProperties.h +++ b/src/cascadia/inc/ControlProperties.h @@ -52,6 +52,8 @@ X(bool, AllowKittyKeyboardMode, true) \ X(winrt::hstring, StartingTitle) \ X(bool, DetectURLs, true) \ + X(bool, CommandTimelineEnabled, true) \ + X(int32_t, CommandTimelineHistoryLimit, 500) \ X(bool, AutoMarkPrompts) \ X(bool, RepositionCursorWithMouse, false) \ X(bool, RainbowSuggestions) \ diff --git a/src/cascadia/wt/wt.rc b/src/cascadia/wt/wt.rc index e3149252f..4985150f0 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,3,0 - PRODUCTVERSION 1,2,3,0 + FILEVERSION 1,2,4,0 + PRODUCTVERSION 1,2,4,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.3.0\0" + VALUE "FileVersion", "1.2.4.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.3\0" + VALUE "ProductVersion", "1.2.4\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 f1a2454ab..7f700a2b4 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,3,0 - PRODUCTVERSION 1,2,3,0 + FILEVERSION 1,2,4,0 + PRODUCTVERSION 1,2,4,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.3.0\0" + VALUE "FileVersion", "1.2.4.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.3\0" + VALUE "ProductVersion", "1.2.4\0" END END BLOCK "VarFileInfo" diff --git a/src/winterm/Branding/ReleaseMetadata.h b/src/winterm/Branding/ReleaseMetadata.h index da5a9deb8..3768520ff 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.3" }; + inline constexpr std::wstring_view ApplicationVersion{ L"1.2.4" }; 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 c830a7075..d7f64f369 100644 --- a/src/winterm/Branding/version.json +++ b/src/winterm/Branding/version.json @@ -1,10 +1,10 @@ { - "applicationVersion": "1.2.3", - "packageVersion": "1.2.3.0", - "moduleVersion": "1.2.3", + "applicationVersion": "1.2.4", + "packageVersion": "1.2.4.0", + "moduleVersion": "1.2.4", "modulePrerelease": "", "channel": "stable", - "tag": "v1.2.3", + "tag": "v1.2.4", "workspaceSchemaVersion": 2, "dockingModelVersion": 1, "shellProtocolVersion": 1, diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.cpp b/src/winterm/CommandTimeline/CommandTimelineModel.cpp index 68916846b..2a70e29d9 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.cpp +++ b/src/winterm/CommandTimeline/CommandTimelineModel.cpp @@ -30,6 +30,56 @@ namespace winTerm::CommandTimeline } } + std::wstring NormalizeCommandTimelineQuery(const std::wstring_view query) + { + if (query.size() <= MaxCommandTimelineQueryLength) + { + return std::wstring{ query }; + } + + std::wstring result{ query.substr(0, MaxCommandTimelineQueryLength) }; + // Never cut a surrogate pair in half; drop the orphaned lead unit. + if (!result.empty() && IsHighSurrogate(result.back())) + { + result.pop_back(); + } + return result; + } + + bool CommandTimelineQueryMatches(const std::wstring_view commandText, + const std::wstring_view query) noexcept + { + if (query.empty()) + { + return true; + } + if (commandText.size() < query.size()) + { + return false; + } + + // Literal case-insensitive substring search. Deliberately not a regex + // and deliberately not fuzzy. + const auto found = std::search( + commandText.begin(), commandText.end(), query.begin(), query.end(), [](const wchar_t left, const wchar_t right) noexcept { + return std::towlower(left) == std::towlower(right); + }); + return found != commandText.end(); + } + + size_t ClampCommandTimelineHistoryLimit(const int64_t historyLimit) noexcept + { + if (historyLimit <= static_cast(MinCommandTimelineHistoryLimit)) + { + return MinCommandTimelineHistoryLimit; + } + if (historyLimit >= static_cast(MaxCommandTimelineHistoryLimit)) + { + return MaxCommandTimelineHistoryLimit; + } + return static_cast(historyLimit); + } + void CommandTimelineViewState::Reset() noexcept { selectedCommandId.reset(); @@ -77,7 +127,7 @@ namespace winTerm::CommandTimeline if (_open) { _reconcile(entries, viewState, false); - _moveSelection(action, entries); + _moveSelection(action); _syncViewState(entries, viewState); } return _snapshot(entries, capability); @@ -92,16 +142,39 @@ namespace winTerm::CommandTimeline if (_open) { _reconcile(entries, viewState, false); - const auto index = _firstVisibleIndex + visualSlot; - if (visualSlot < _visibleCapacity && index < entries.size()) + const auto position = _firstVisiblePosition + visualSlot; + if (visualSlot < _visibleCapacity && position < _filtered.size()) { - _selectedIndex = index; + _selectedPosition = position; _syncViewState(entries, viewState); } } return _snapshot(entries, capability); } + // Applying a query only changes which commands are projected. The selected + // command is preserved whenever it still matches, so typing never silently + // retargets an action. + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::SetQuery( + const std::wstring_view query, + const std::span entries, + CommandTimelineViewState& viewState, + const ShellIntegrationCapability capability) + { + auto normalized = NormalizeCommandTimelineQuery(query); + if (normalized != _query) + { + _query = std::move(normalized); + _wheelDeltaRemainder = 0; + } + + if (_open) + { + _reconcile(entries, viewState, false); + } + return _snapshot(entries, capability); + } + CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::ApplyWheelDelta( const int delta, const std::span entries, @@ -115,6 +188,10 @@ namespace winTerm::CommandTimeline } _reconcile(entries, viewState, false); + if (_filtered.empty()) + { + return _snapshot(entries, capability); + } _wheelSettlePending = true; const auto accumulated = static_cast(_wheelDeltaRemainder) + delta; _wheelDeltaRemainder = gsl::narrow_cast(std::clamp( @@ -125,7 +202,7 @@ namespace winTerm::CommandTimeline while (_wheelDeltaRemainder >= deltaPerEntry) { _wheelDeltaRemainder -= deltaPerEntry; - if (!_moveSelection(NavigationAction::Previous, entries)) + if (!_moveSelection(NavigationAction::Previous)) { _wheelDeltaRemainder = 0; break; @@ -134,7 +211,7 @@ namespace winTerm::CommandTimeline while (_wheelDeltaRemainder <= -deltaPerEntry) { _wheelDeltaRemainder += deltaPerEntry; - if (!_moveSelection(NavigationAction::Next, entries)) + if (!_moveSelection(NavigationAction::Next)) { _wheelDeltaRemainder = 0; break; @@ -153,9 +230,15 @@ namespace winTerm::CommandTimeline void CommandTimelineNavigationModel::Close() noexcept { - _selectedIndex.reset(); + // Closing drops the query and the filtered projection along with the + // rest of the UI-only state; a query is never persisted. + _filtered.clear(); + _filtered.shrink_to_fit(); + _query.clear(); + _query.shrink_to_fit(); + _selectedPosition.reset(); _lastLatestCommandId.reset(); - _firstVisibleIndex = 0; + _firstVisiblePosition = 0; _visibleCapacity = 1; _wheelDeltaRemainder = 0; _open = false; @@ -182,88 +265,181 @@ namespace winTerm::CommandTimeline return _visibleCapacity; } + const std::wstring& CommandTimelineNavigationModel::Query() const noexcept + { + return _query; + } + + size_t CommandTimelineNavigationModel::FilteredCount() const noexcept + { + return _filtered.size(); + } + + void CommandTimelineNavigationModel::_rebuildFilter(const std::span entries) + { + _filtered.clear(); + if (_query.empty()) + { + _filtered.resize(entries.size()); + for (size_t index = 0; index < entries.size(); ++index) + { + _filtered[index] = index; + } + return; + } + + // Only the bounded cached command text is searched. Output is never + // consulted and the terminal buffer is never rescanned. + for (size_t index = 0; index < entries.size(); ++index) + { + if (CommandTimelineQueryMatches(entries[index].cachedCommandText, _query)) + { + _filtered.emplace_back(index); + } + } + } + + std::optional CommandTimelineNavigationModel::_positionOf(const size_t entryIndex) const noexcept + { + const auto found = std::lower_bound(_filtered.begin(), _filtered.end(), entryIndex); + if (found == _filtered.end() || *found != entryIndex) + { + return std::nullopt; + } + return gsl::narrow_cast(std::distance(_filtered.begin(), found)); + } + + // Chooses the surviving match closest to a command that is no longer in the + // projection, so a selection is never silently dropped to the bottom. + size_t CommandTimelineNavigationModel::_nearestPosition(const std::span entries, + const CommandId& id) const noexcept + { + if (_filtered.empty()) + { + return 0; + } + if (id.paneSessionId != entries[_filtered.front()].id.paneSessionId) + { + return _filtered.size() - 1; + } + + const auto next = std::lower_bound( + _filtered.begin(), _filtered.end(), id.sequence, [&](const size_t index, const uint64_t sequence) { + return entries[index].id.sequence < sequence; + }); + if (next == _filtered.begin()) + { + return 0; + } + if (next == _filtered.end()) + { + return _filtered.size() - 1; + } + + const auto nextPosition = gsl::narrow_cast(std::distance(_filtered.begin(), next)); + const auto previousPosition = nextPosition - 1; + const auto nextDistance = entries[*next].id.sequence - id.sequence; + const auto previousDistance = id.sequence - entries[_filtered[previousPosition]].id.sequence; + return previousDistance <= nextDistance ? previousPosition : nextPosition; + } + void CommandTimelineNavigationModel::_reconcile(const std::span entries, CommandTimelineViewState& viewState, const bool allowFollowLatest) { - if (entries.empty()) + _rebuildFilter(entries); + + if (_filtered.empty()) { - _selectedIndex.reset(); - _lastLatestCommandId.reset(); - _firstVisibleIndex = 0; + _selectedPosition.reset(); + _lastLatestCommandId = entries.empty() ? std::optional{} : entries.back().id; + _firstVisiblePosition = 0; _syncViewState(entries, viewState); return; } - const auto wasFollowingLatest = allowFollowLatest && - _lastLatestCommandId.has_value() && - viewState.selectedCommandId == _lastLatestCommandId; + // Following latest only applies when the newest command is itself part + // of the current projection. A new command that does not match the + // query must not pull the selection anywhere. + const auto latestPosition = _filtered.back(); + const auto followingLatest = allowFollowLatest && + _lastLatestCommandId.has_value() && + viewState.selectedCommandId == _lastLatestCommandId && + !entries.empty() && + entries[latestPosition].id == entries.back().id; + std::optional selected; - if (wasFollowingLatest) + if (followingLatest) { - selected = entries.size() - 1; + selected = _filtered.size() - 1; } else if (viewState.selectedCommandId.has_value()) { - selected = _findCommand(entries, *viewState.selectedCommandId); + if (const auto index = _findCommand(entries, *viewState.selectedCommandId)) + { + selected = _positionOf(*index); + } if (!selected.has_value()) { - selected = _findNearestCommand(entries, *viewState.selectedCommandId); + selected = _nearestPosition(entries, *viewState.selectedCommandId); } } else { - selected = entries.size() - 1; + selected = _filtered.size() - 1; } - _selectedIndex = selected; + _selectedPosition = selected; - const auto maxFirst = entries.size() > _visibleCapacity ? entries.size() - _visibleCapacity : 0; + const auto maxFirst = _filtered.size() > _visibleCapacity ? _filtered.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()) + for (size_t position = 0; position < _filtered.size(); ++position) { - restoredAnchor = gsl::narrow_cast(std::distance(entries.begin(), anchor)); + if (entries[_filtered[position]].nativeMarkId == *viewState.visibleNativeAnchor) + { + restoredAnchor = position; + break; + } } } if (restoredAnchor.has_value()) { - _firstVisibleIndex = std::min(*restoredAnchor, maxFirst); + _firstVisiblePosition = 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); + _firstVisiblePosition = *_selectedPosition > desiredSlot ? *_selectedPosition - desiredSlot : 0; + _firstVisiblePosition = std::min(_firstVisiblePosition, maxFirst); } - if (*_selectedIndex < _firstVisibleIndex) + if (*_selectedPosition < _firstVisiblePosition) { - _firstVisibleIndex = *_selectedIndex; + _firstVisiblePosition = *_selectedPosition; } - else if (*_selectedIndex >= _firstVisibleIndex + _visibleCapacity) + else if (*_selectedPosition >= _firstVisiblePosition + _visibleCapacity) { - _firstVisibleIndex = *_selectedIndex - _visibleCapacity + 1; + _firstVisiblePosition = *_selectedPosition - _visibleCapacity + 1; + } + _firstVisiblePosition = std::min(_firstVisiblePosition, maxFirst); + if (!entries.empty()) + { + _lastLatestCommandId = entries.back().id; } - _firstVisibleIndex = std::min(_firstVisibleIndex, maxFirst); - _lastLatestCommandId = entries.back().id; _syncViewState(entries, viewState); } - bool CommandTimelineNavigationModel::_moveSelection(const NavigationAction action, - const std::span entries) + bool CommandTimelineNavigationModel::_moveSelection(const NavigationAction action) { - if (!_selectedIndex.has_value() || entries.empty()) + if (!_selectedPosition.has_value() || _filtered.empty()) { return false; } - auto next = *_selectedIndex; + auto next = *_selectedPosition; switch (action) { case NavigationAction::Previous: @@ -274,28 +450,28 @@ namespace winTerm::CommandTimeline --next; break; case NavigationAction::Next: - if (next + 1 >= entries.size()) + if (next + 1 >= _filtered.size()) { return false; } ++next; break; case NavigationAction::PageFirst: - next = _firstVisibleIndex; + next = _firstVisiblePosition; break; case NavigationAction::PageLast: - next = std::min(entries.size(), _firstVisibleIndex + _visibleCapacity) - 1; + next = std::min(_filtered.size(), _firstVisiblePosition + _visibleCapacity) - 1; break; } - _selectedIndex = next; - if (next < _firstVisibleIndex) + _selectedPosition = next; + if (next < _firstVisiblePosition) { - _firstVisibleIndex = next; + _firstVisiblePosition = next; } - else if (next >= _firstVisibleIndex + _visibleCapacity) + else if (next >= _firstVisiblePosition + _visibleCapacity) { - _firstVisibleIndex = next - _visibleCapacity + 1; + _firstVisiblePosition = next - _visibleCapacity + 1; } return true; } @@ -303,47 +479,77 @@ namespace winTerm::CommandTimeline void CommandTimelineNavigationModel::_syncViewState(const std::span entries, CommandTimelineViewState& viewState) const { - if (!_selectedIndex.has_value() || entries.empty()) + if (!_selectedPosition.has_value() || _filtered.empty() || entries.empty()) { - viewState.selectedCommandId.reset(); + // A query with no results must not clear the selected command; the + // selection is only dropped when there are no entries at all. + if (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; + viewState.selectedCommandId = entries[_filtered[*_selectedPosition]].id; + viewState.visibleNativeAnchor = entries[_filtered[_firstVisiblePosition]].nativeMarkId; + viewState.selectedVisualSlot = *_selectedPosition - _firstVisiblePosition; } CommandTimelinePresentationSnapshot CommandTimelineNavigationModel::_snapshot( const std::span entries, const ShellIntegrationCapability capability) const { + const auto emptyState = [&]() noexcept { + if (!_filtered.empty()) + { + return CommandTimelineEmptyState::None; + } + if (!_query.empty() && !entries.empty()) + { + return CommandTimelineEmptyState::NoMatchingCommands; + } + switch (capability) + { + case ShellIntegrationCapability::Limited: + return CommandTimelineEmptyState::ShellUnsupported; + case ShellIntegrationCapability::Full: + return CommandTimelineEmptyState::NoCommands; + default: + return CommandTimelineEmptyState::WaitingForShell; + } + }(); + CommandTimelinePresentationSnapshot result{ .capability = capability, + .emptyState = emptyState, .totalEntryCount = entries.size(), - .firstVisibleIndex = _firstVisibleIndex, - .selectedVisualSlot = _selectedIndex.has_value() ? *_selectedIndex - _firstVisibleIndex : 0, + .filteredEntryCount = _filtered.size(), + .firstVisibleIndex = _firstVisiblePosition, + .selectedVisualSlot = _selectedPosition.has_value() ? *_selectedPosition - _firstVisiblePosition : 0, .wheelDeltaRemainder = _wheelDeltaRemainder, .open = _open, + .filtered = !_query.empty(), .wheelSettlePending = _wheelSettlePending, }; - if (!_open || entries.empty()) + if (!_open || _filtered.empty()) { return result; } - const auto end = std::min(entries.size(), _firstVisibleIndex + _visibleCapacity); - result.visibleEntries.reserve(end - _firstVisibleIndex); - for (auto index = _firstVisibleIndex; index < end; ++index) + // Only the rows that fit on screen are materialized, whatever the size + // of the history behind them. + const auto end = std::min(_filtered.size(), _firstVisiblePosition + _visibleCapacity); + result.visibleEntries.reserve(end - _firstVisiblePosition); + for (auto position = _firstVisiblePosition; position < end; ++position) { - const auto& entry = entries[index]; + const auto& entry = entries[_filtered[position]]; result.visibleEntries.emplace_back(CommandTimelineVisibleEntry{ .id = entry.id, .commandText = entry.cachedCommandText, .executionResult = _effectiveResult(entry), - .selected = _selectedIndex == index, + .selected = _selectedPosition == position, }); } return result; @@ -826,6 +1032,46 @@ namespace winTerm::CommandTimeline } } + void CommandTimelineIndex::SetHistoryLimit(const size_t historyLimit) + { + const auto clamped = ClampCommandTimelineHistoryLimit(gsl::narrow_cast(historyLimit)); + if (_closed || _historyLimit == clamped) + { + return; + } + + _historyLimit = clamped; + // Raising the limit must not resurrect anything: entries already + // dropped are gone, and sequence numbers are never reissued. + if (_applyHistoryLimit()) + { + _rebuildLookups(); + _incrementRevision(); + } + } + + // Drops the oldest entries first until the history fits the limit. + bool CommandTimelineIndex::_applyHistoryLimit() + { + if (_entries.size() <= _historyLimit) + { + return false; + } + + const auto excess = _entries.size() - _historyLimit; + for (size_t index = 0; index < excess; ++index) + { + _lifecycleByNativeMark.erase(_entries[index].nativeMarkId); + } + _entries.erase(_entries.begin(), _entries.begin() + gsl::narrow_cast(excess)); + return true; + } + + size_t CommandTimelineIndex::HistoryLimit() const noexcept + { + return _historyLimit; + } + void CommandTimelineIndex::Close() noexcept { if (_closed) @@ -937,6 +1183,8 @@ namespace winTerm::CommandTimeline .nativeRangeValid = true, .shellIntegrationCapability = _capability, }); + // The newest command is always kept; eviction takes from the front. + _applyHistoryLimit(); _rebuildLookups(); return _entries.back(); } @@ -1041,6 +1289,7 @@ namespace winTerm::CommandTimeline _nativeRevision = snapshot.nativeRevision; _bootstrapped = true; + changed = _applyHistoryLimit() || changed; _rebuildLookups(); if (changed) { diff --git a/src/winterm/CommandTimeline/CommandTimelineModel.h b/src/winterm/CommandTimeline/CommandTimelineModel.h index 465503095..dfb93214f 100644 --- a/src/winterm/CommandTimeline/CommandTimelineModel.h +++ b/src/winterm/CommandTimeline/CommandTimelineModel.h @@ -19,6 +19,25 @@ namespace winTerm::CommandTimeline { inline constexpr size_t DefaultMaxCachedCommandText = 4096; + // Phase 4 search and history bounds. The query cap is expressed in UTF-16 + // code units because that is what the input box and the cached command text + // are measured in. + inline constexpr size_t MaxCommandTimelineQueryLength = 256; + inline constexpr size_t DefaultCommandTimelineHistoryLimit = 500; + inline constexpr size_t MinCommandTimelineHistoryLimit = 50; + inline constexpr size_t MaxCommandTimelineHistoryLimit = 5000; + + // Truncates a query to MaxCommandTimelineQueryLength without ever leaving a + // lone surrogate behind. + std::wstring NormalizeCommandTimelineQuery(std::wstring_view query); + + // Literal, case-insensitive substring match. No regex, no fuzzy matching, + // and only ever applied to cached command text. + bool CommandTimelineQueryMatches(std::wstring_view commandText, std::wstring_view query) noexcept; + + // Clamps a configured history limit into the supported range. + size_t ClampCommandTimelineHistoryLimit(int64_t historyLimit) noexcept; + enum class CommandLifecycleState : uint8_t { Command, @@ -114,15 +133,30 @@ namespace winTerm::CommandTimeline bool selected{ false }; }; + // Which "nothing to show" message the overlay should present. These are + // distinct states on purpose: an unsupported shell must never be reported + // as simply having run no commands. + enum class CommandTimelineEmptyState : uint8_t + { + None, + WaitingForShell, + ShellUnsupported, + NoCommands, + NoMatchingCommands, + }; + struct CommandTimelinePresentationSnapshot { std::vector visibleEntries; ShellIntegrationCapability capability{ ShellIntegrationCapability::Unknown }; + CommandTimelineEmptyState emptyState{ CommandTimelineEmptyState::None }; size_t totalEntryCount{}; + size_t filteredEntryCount{}; size_t firstVisibleIndex{}; size_t selectedVisualSlot{}; int wheelDeltaRemainder{}; bool open{ false }; + bool filtered{ false }; bool wheelSettlePending{ false }; }; @@ -152,6 +186,10 @@ namespace winTerm::CommandTimeline CommandTimelineViewState& viewState, ShellIntegrationCapability capability, int deltaPerEntry = DefaultWheelDeltaPerEntry); + CommandTimelinePresentationSnapshot SetQuery(std::wstring_view query, + std::span entries, + CommandTimelineViewState& viewState, + ShellIntegrationCapability capability); void SettleWheel() noexcept; void Close() noexcept; @@ -159,26 +197,36 @@ namespace winTerm::CommandTimeline bool WheelSettlePending() const noexcept; int WheelDeltaRemainder() const noexcept; size_t VisibleCapacity() const noexcept; + const std::wstring& Query() const noexcept; + size_t FilteredCount() const noexcept; private: void _reconcile(std::span entries, CommandTimelineViewState& viewState, bool allowFollowLatest); - bool _moveSelection(NavigationAction action, - std::span entries); + void _rebuildFilter(std::span entries); + bool _moveSelection(NavigationAction action); void _syncViewState(std::span entries, CommandTimelineViewState& viewState) const; CommandTimelinePresentationSnapshot _snapshot(std::span entries, ShellIntegrationCapability capability) const; + std::optional _positionOf(size_t entryIndex) const noexcept; + size_t _nearestPosition(std::span entries, + const CommandId& id) const noexcept; 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; + // Positions into _filtered, which holds indices into the caller's + // entries span. An empty query keeps every entry, so the unfiltered + // case walks the same code path. + std::vector _filtered; + std::wstring _query; + std::optional _selectedPosition; std::optional _lastLatestCommandId; - size_t _firstVisibleIndex{}; + size_t _firstVisiblePosition{}; size_t _visibleCapacity{ 1 }; int _wheelDeltaRemainder{}; bool _open{ false }; @@ -306,6 +354,7 @@ namespace winTerm::CommandTimeline void ReconcileReflow(std::span survivingNativeMarkIds, uint64_t nativeRevision); void SetCapabilityFallback(ShellIntegrationCapability capability); + void SetHistoryLimit(size_t historyLimit); void Close() noexcept; const CommandTimelineEntry* Find(const CommandId& id) const noexcept; @@ -316,6 +365,7 @@ namespace winTerm::CommandTimeline uint64_t Revision() const noexcept; uint64_t NativeRevision() const noexcept; uint64_t NextSequence() const noexcept; + size_t HistoryLimit() const noexcept; size_t BootstrapScanCount() const noexcept; size_t CachedCommandTextCharacters() const noexcept; bool IsBootstrapped() const noexcept; @@ -328,6 +378,7 @@ namespace winTerm::CommandTimeline void _finishIncompleteCurrent(std::optional timestamp); void _observeLifecycleCapability(uint64_t nativeMarkId, LifecycleEventKind kind); void _refreshEntryCapabilities(); + bool _applyHistoryLimit(); void _rebuildLookups(); void _incrementRevision() noexcept; @@ -339,6 +390,7 @@ namespace winTerm::CommandTimeline std::unordered_map _lifecycleByNativeMark; std::optional _currentSequence; uint64_t _nextSequence{ 1 }; + size_t _historyLimit{ DefaultCommandTimelineHistoryLimit }; uint64_t _revision{}; uint64_t _nativeRevision{}; size_t _bootstrapScanCount{}; diff --git a/src/winterm/Workspaces/Model/WorkspaceDescriptor.h b/src/winterm/Workspaces/Model/WorkspaceDescriptor.h index b37c9c29e..375e890bf 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.3" }; + std::string applicationVersion{ "1.2.4" }; 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 023703f96..17f19856a 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.3"); + workspace.applicationVersion = StringOrDefault(json, "applicationVersion", "1.2.4"); workspace.protocolVersion = UIntOrDefault(json, "protocolVersion", 1); workspace.dockingModelVersion = UIntOrDefault(json, "dockingModelVersion", DockingModelVersion); if (const auto& startup = json["startupBehavior"]; !startup.isNull())