diff --git a/.config/tsaoptions.json b/.config/tsaoptions.json new file mode 100644 index 000000000..0f1ded2de --- /dev/null +++ b/.config/tsaoptions.json @@ -0,0 +1,9 @@ +{ + "instanceUrl": "https://msazure.visualstudio.com", + "projectName": "One", + "areaPath": "One\\MGMT\\Compute\\Powershell\\Powershell\\PowerShell Core", + "notificationAliases": [ + "dongbow@microsoft.com", + "slee@microsoft.com" + ] +} diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..686e5e7a0 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,10 @@ +# Microsoft Open Source Code of Conduct + +This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +Resources: + +- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) +- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) +- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns +- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 218c5d543..52fb53194 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -65,23 +65,7 @@ Additional references: ### Bootstrap, Build and Test -To build `PSReadLine` on Windows, Linux, or macOS, -you must have the following installed: - -* .NET Core SDK 2.1.802 or [a newer version](https://www.microsoft.com/net/download) -* The PowerShell modules `InvokeBuild` and `platyPS` - -The build script `build.ps1` can be used to bootstrap, build and test the project. - -* Bootstrap: `./build.ps1 -Bootstrap` -* Build: - * Targeting .NET 4.6.2 (Windows only): `./build.ps1 -Configuration Debug -Framework net462` - * Targeting .NET Core: `./build.ps1 -Configuration Debug -Framework netcoreapp2.1` -* Test: - * Targeting .NET 4.6.2 (Windows only): `./build.ps1 -Test -Configuration Debug -Framework net462` - * Targeting .NET Core: `./build.ps1 -Test -Configuration Debug -Framework netcoreapp2.1` - -After build, the produced artifacts can be found at `/bin/Debug`. +See the [Building](../README.md#building) section in README for details. ### Submitting Pull Request @@ -127,5 +111,5 @@ After build, the produced artifacts can be found at `/bin/ [platy-ps]: https://www.powershellgallery.com/packages/platyPS [using-prs]: https://help.github.com/articles/using-pull-requests/ [fork-a-repo]: https://help.github.com/articles/fork-a-repo/ -[vuln-reporting]: https://github.com/PowerShell/PowerShell/blob/master/.github/SECURITY.md +[vuln-reporting]: SECURITY.md [closing-via-message]: https://help.github.com/articles/closing-issues-via-commit-messages/ diff --git a/.github/ISSUE_TEMPLATE/Documentation_Issue.yaml b/.github/ISSUE_TEMPLATE/Documentation_Issue.yaml index f7388737e..feee51172 100644 --- a/.github/ISSUE_TEMPLATE/Documentation_Issue.yaml +++ b/.github/ISSUE_TEMPLATE/Documentation_Issue.yaml @@ -1,16 +1,15 @@ name: Documentation Issue πŸ“š -description: Report issues in our documentation. +description: Report documentation issues at https://github.com/MicrosoftDocs/PowerShell-Docs/issues/new/choose labels: Issue-Docs body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this documentation Issue! + Documents for PSReadLine are hosted in the [PowerShell-Docs](https://github.com/MicrosoftDocs/PowerShell-Docs) repo. So, please submit the issue directly in that repo for a quicker response and fix. - type: checkboxes attributes: label: Prerequisites options: - label: Write a descriptive title. required: true -- type: textarea - attributes: - label: Issue summary - description: Briefly describe which document needs to be corrected and why. - validations: - required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..f941d308b --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,41 @@ + + +## Security + +Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin) and [PowerShell](https://github.com/PowerShell). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). + +If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). + +You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). + +Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). + + diff --git a/.github/policies/resourceManagement.yml b/.github/policies/resourceManagement.yml new file mode 100644 index 000000000..d2bb10360 --- /dev/null +++ b/.github/policies/resourceManagement.yml @@ -0,0 +1,315 @@ +id: +name: GitOps.PullRequestIssueManagement +description: GitOps.PullRequestIssueManagement primitive +owner: +resource: repository +disabled: false +where: +configuration: + resourceManagementConfiguration: + scheduledSearches: + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isPullRequest + - isOpen + - hasLabel: + label: Needs-Author Feedback + - noActivitySince: + days: 7 + actions: + - closeIssue + - addReply: + reply: This issue is closed because it has been marked as requiring author feedback but has not had any activity for **7 days**. If you think the issue is still relevant, please reopen and provide your feedback. + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isPullRequest + - isOpen + - hasLabel: + label: Needs-Author Feedback + - noActivitySince: + days: 14 + - isNotLabeledWith: + label: Status-No Recent Activity + actions: + - addLabel: + label: Status-No Recent Activity + - addReply: + reply: This pull request has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for **14 days**. It will be closed if no further activity occurs **within 7 days of this comment**. + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isIssue + - isOpen + - hasLabel: + label: Needs-Author Feedback + - noActivitySince: + days: 7 + actions: + - addReply: + reply: This issue is closed because it has been marked as requiring author feedback but has not had any activity for **7 days**. If you think the issue is still relevant, please reopen and provide your feedback. + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-Answered + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-By Design + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-Declined + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-Duplicate + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-External + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-Fixed + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-Not Repro + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-Wont Fix + actions: + - closeIssue + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Resolution-No Activity + actions: + - closeIssue + - addReply: + reply: This issue is closed because it has had no activity and on older unsupported versions of PowerShell or PSReadLine. Please try again on latest versions of both and if its still an issue please submit a new issue. + - description: + frequencies: + - hourly: + hour: 6 + filters: + - isOpen + - hasLabel: + label: Needs-Repro + - noActivitySince: + days: 7 + actions: + - closeIssue + - addReply: + reply: This issue is closed because it has been marked as requiring repro steps but has not had any activity for **7 days**. If you think the issue is still relevant, please reopen and provide your feedback. + eventResponderTasks: + - if: + - payloadType: Issue_Comment + - isAction: + action: Created + - isActivitySender: + issueAuthor: True + - hasLabel: + label: Needs-Author Feedback + - isOpen + then: + - addLabel: + label: 'Needs-Attention :wave:' + - removeLabel: + label: Needs-Author Feedback + description: + - if: + - payloadType: Pull_Request + - isAction: + action: Opened + then: + - addCodeFlowLink + description: + - if: + - payloadType: Pull_Request_Review + - isAction: + action: Submitted + - isReviewState: + reviewState: Changes_requested + then: + - addLabel: + label: Needs-Author Feedback + description: + - if: + - payloadType: Pull_Request + - isActivitySender: + issueAuthor: True + - not: + isAction: + action: Closed + - hasLabel: + label: Needs-Author Feedback + then: + - removeLabel: + label: Needs-Author Feedback + description: + - if: + - payloadType: Issue_Comment + - isActivitySender: + issueAuthor: True + - hasLabel: + label: Needs-Author Feedback + then: + - removeLabel: + label: Needs-Author Feedback + description: + - if: + - payloadType: Pull_Request_Review + - isActivitySender: + issueAuthor: True + - hasLabel: + label: Needs-Author Feedback + then: + - removeLabel: + label: Needs-Author Feedback + description: + - if: + - payloadType: Pull_Request + - hasLabel: + label: Status-No Recent Activity + - isAction: + action: Closed + then: + - removeLabel: + label: Status-No Recent Activity + description: + - if: + - payloadType: Issue_Comment + - hasLabel: + label: Status-No Recent Activity + then: + - removeLabel: + label: Status-No Recent Activity + description: + - if: + - payloadType: Pull_Request_Review + - hasLabel: + label: Status-No Recent Activity + then: + - removeLabel: + label: Status-No Recent Activity + description: + - if: + - payloadType: Pull_Request + - hasLabel: + label: Auto Merge + then: + - enableAutoMerge: + mergeMethod: Squash + description: + - if: + - payloadType: Pull_Request + - labelRemoved: + label: Auto Merge + then: + - disableAutoMerge + description: + - if: + - payloadType: Issues + - isAction: + action: Closed + - hasLabel: + label: 'Needs-Triage :mag:' + then: + - removeLabel: + label: 'Needs-Triage :mag:' + description: + - if: + - payloadType: Pull_Request + then: + - inPrLabel: + label: In-PR + description: + - if: + - payloadType: Issue_Comment + - hasLabel: + label: Needs-Repro + - isActivitySender: + issueAuthor: True + then: + - reopenIssue + - removeLabel: + label: Needs-Repro + - addLabel: + label: 'Needs-Attention :wave:' + description: + - if: + - payloadType: Issues + - and: + - isAction: + action: Opened + - not: + and: + - isAssignedToSomeone + - isLabeled + then: + - addLabel: + label: 'Needs-Triage :mag:' + description: 'Adding needs triage label to newly opened issues' +onFailure: +onSuccess: diff --git a/.github/workflows/IssuePreTriage.yml b/.github/workflows/IssuePreTriage.yml index e7107f3ae..2bf3e29cb 100644 --- a/.github/workflows/IssuePreTriage.yml +++ b/.github/workflows/IssuePreTriage.yml @@ -21,9 +21,12 @@ jobs: name: Process new issues timeout-minutes: 20 runs-on: ubuntu-latest + permissions: + contents: read + issues: write steps: - name: checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: do-work run: | diff --git a/.gitignore b/.gitignore index 8a1e32649..fb0d46c89 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,6 @@ PSReadline.zip [Oo]bj/ .ionide/ -# VSCode directories that are not at the repository root -/**/.vscode/ - # mstest test results TestResults FakesAssemblies/ diff --git a/.pipelines/PSReadLine-Official.yml b/.pipelines/PSReadLine-Official.yml new file mode 100644 index 000000000..db0f855cb --- /dev/null +++ b/.pipelines/PSReadLine-Official.yml @@ -0,0 +1,326 @@ +################################################################################# +# OneBranch Pipelines # +# This pipeline was created by EasyStart from a sample located at: # +# https://aka.ms/obpipelines/easystart/samples # +# Documentation: https://aka.ms/obpipelines # +# Yaml Schema: https://aka.ms/obpipelines/yaml/schema # +# Retail Tasks: https://aka.ms/obpipelines/tasks # +# Support: https://aka.ms/onebranchsup # +################################################################################# + +name: PSReadLine-ModuleBuild-$(Build.BuildId) +trigger: none +pr: none + +parameters: + - name: Release + type: boolean + default: true # Set false to skip release stage + +variables: + DOTNET_CLI_TELEMETRY_OPTOUT: 1 + POWERSHELL_TELEMETRY_OPTOUT: 1 + DOTNET_NOLOGO: 1 + WindowsContainerImage: onebranch.azurecr.io/windows/ltsc2022/vse2022:latest + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@templates + parameters: + release: + category: NonAzure + featureFlags: + WindowsHostVersion: + Version: 2022 + Network: Netlock + globalSdl: + disableLegacyManifest: true + cg: # Component Governance parameters. Ignore test components. + ignoreDirectories: $(Build.SourcesDirectory)\PSReadLine\MockPSConsole,$(Build.SourcesDirectory)\PSReadLine\test + sbom: + enabled: true + packageName: PSReadLine + buildComponentPath: $(Build.SourcesDirectory)\PSReadLine\PSReadLine + codeql: + compiled: + enabled: true + asyncSdl: # https://aka.ms/obpipelines/asyncsdl + enabled: true + forStages: [buildstage] + credscan: + enabled: true + scanFolder: $(Build.SourcesDirectory)\PSReadLine\PSReadLine + binskim: + enabled: true + apiscan: + enabled: false + + stages: + - stage: buildstage + displayName: Build and Sign PSReadLine + jobs: + - job: buildjob + displayName: Build PSReadLine Files + variables: + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + - name: repoRoot + value: $(Build.SourcesDirectory)\PSReadLine + - name: ob_sdl_tsa_configFile + value: $(repoRoot)\.config\tsaoptions.json + - name: signSrcPath + value: $(repoRoot)\bin\Release\PSReadLine + - name: ob_sdl_sbom_enabled + value: true + - name: ob_signing_setup_enabled + value: true + #CodeQL tasks added manually to workaround signing failures + - name: ob_sdl_codeql_compiled_enabled + value: false + + pool: + type: windows + steps: + - checkout: self + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + + - pwsh: | + if (-not (Test-Path $(repoRoot)/.config/tsaoptions.json)) { + throw "tsaoptions.json does not exist under $(repoRoot)/.config" + } + displayName: Test if tsaoptions.json exists + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + + - task: UseDotNet@2 + displayName: Bootstrap - install .NET + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + inputs: + packageType: sdk + + - pwsh: | + Write-Host "PS Version: $($PSVersionTable.PSVersion)" + Register-PSResourceRepository -Name CFS -Uri "https://pkgs.dev.azure.com/powershell/PowerShell/_packaging/PowerShellGalleryMirror/nuget/v3/index.json" -Trusted + Install-PSResource -Repository CFS -Name InvokeBuild -Version 5.12.1 -Verbose + displayName: Bootstrap - install InvokeBuild + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + + # Add CodeQL Init task right before your 'Build' step. + - task: CodeQL3000Init@0 + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + inputs: + Enabled: true + AnalyzeInPipeline: false + Language: csharp + + - pwsh: | + Write-Host "PS Version: $($($PSVersionTable.PSVersion))" + Set-Location -Path '$(repoRoot)' + .\build.ps1 -Configuration Release + displayName: Build + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + + # Add CodeQL Finalize task right after your 'Build' step. + - task: CodeQL3000Finalize@0 + condition: always() + env: + # Set ob_restore_phase to run this step before 'πŸ”’ Setup Signing' step. + ob_restore_phase: true + + - task: onebranch.pipeline.signing@1 + displayName: Sign 1st party files + inputs: + command: 'sign' + signing_profile: external_distribution + files_to_sign: '*.psd1;*.psm1;*.ps1;*.ps1xml;**\Microsoft*.dll;!Microsoft.PowerShell.Pager.dll' + search_root: $(signSrcPath) + + # Verify the signatures + - pwsh: | + $HasInvalidFiles = $false + $WrongCert = @{} + Get-ChildItem -Path $(signSrcPath) -Recurse -Include "*.dll","*.ps*1*" | ` + Get-AuthenticodeSignature | ForEach-Object { + Write-Host "$($_.Path): $($_.Status)" + if ($_.Status -ne 'Valid') { $HasInvalidFiles = $true } + if ($_.SignerCertificate.Subject -notmatch 'CN=Microsoft Corporation.*') { + $WrongCert.Add($_.Path, $_.SignerCertificate.Subject) + } + } + + if ($HasInvalidFiles) { throw "Authenticode verification failed. There is one or more invalid files." } + if ($WrongCert.Count -gt 0) { + $WrongCert + throw "Certificate should have the subject starts with 'Microsoft Corporation'" + } + + Write-Host "Display files in the folder ..." -ForegroundColor Yellow + Get-ChildItem -Path $(signSrcPath) -Recurse | Out-String -Width 120 -Stream + displayName: 'Verify the signed files' + + - task: CopyFiles@2 + displayName: "Copy signed files to ob_outputDirectory - '$(ob_outputDirectory)'" + inputs: + SourceFolder: $(signSrcPath) + Contents: '**\*' + TargetFolder: $(ob_outputDirectory) + + - pwsh: | + $versionInfo = Get-Item "$(signSrcPath)\Microsoft.PowerShell.PSReadLine.dll" | ForEach-Object VersionInfo + $moduleVersion = $versionInfo.ProductVersion.Split('+')[0] + $vstsCommandString = "vso[task.setvariable variable=ob_sdl_sbom_packageversion]${moduleVersion}" + + Write-Host "sending $vstsCommandString" + Write-Host "##$vstsCommandString" + displayName: Setup SBOM Package Version + + - job: nupkg + dependsOn: buildjob + displayName: Package PSReadLine module + variables: + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + - name: repoRoot + value: $(Build.SourcesDirectory)\PSReadLine + - name: ob_sdl_tsa_configFile + value: $(repoRoot)\.config\tsaoptions.json + - name: ob_sdl_sbom_enabled + value: true + - name: signOutPath + value: $(repoRoot)\signed\PSReadLine + - name: nugetPath + value: $(repoRoot)\signed\NuGetPackage + - name: ob_signing_setup_enabled + value: true + # This job is not compiling code, so disable codeQL + - name: ob_sdl_codeql_compiled_enabled + value: false + + pool: + type: windows + steps: + - checkout: self + env: + ob_restore_phase: true # This ensures this done in restore phase to workaround signing issue + + - task: DownloadPipelineArtifact@2 + displayName: 'Download build files' + env: + ob_restore_phase: true # This ensures this done in restore phase to workaround signing issue + inputs: + targetPath: $(signOutPath) + artifact: drop_buildstage_buildjob + + - pwsh: | + if (Test-Path '$(signOutPath)\_manifest') { + Write-Verbose -Verbose "Delete SBOM files ..." + Remove-Item -Path '$(signOutPath)\_manifest' -Recurse -Force + } + Get-ChildItem $(signOutPath) -Recurse | Out-String -Width 120 -Stream + New-Item -Path $(nugetPath) -ItemType Directory > $null + displayName: Capture artifacts structure + env: + ob_restore_phase: true # This ensures this done in restore phase to workaround signing issue + + - pwsh: | + try { + $RepoName = "PSRLLocal" + Register-PSRepository -Name $RepoName -SourceLocation $(nugetPath) -PublishLocation $(nugetPath) -InstallationPolicy Trusted + Publish-Module -Repository $RepoName -Path $(signOutPath) + } finally { + Unregister-PSRepository -Name $RepoName -ErrorAction SilentlyContinue + } + Get-ChildItem -Path $(nugetPath) | Out-String -Width 120 -Stream + displayName: 'Create the NuGet package' + env: + ob_restore_phase: true # This ensures this done in restore phase to workaround signing issue + + - task: onebranch.pipeline.signing@1 + displayName: Sign nupkg + inputs: + command: 'sign' + signing_profile: external_distribution + files_to_sign: '*.nupkg' + search_root: $(nugetPath) + + - task: CopyFiles@2 + displayName: "Copy nupkg to ob_outputDirectory - '$(ob_outputDirectory)'" + inputs: + SourceFolder: $(nugetPath) + Contents: '**\*' + TargetFolder: $(ob_outputDirectory) + + - stage: release + dependsOn: buildstage + displayName: Release PSReadLine + variables: + ob_release_environment: Production + + jobs: + - job: validation + displayName: Manual validation + pool: + type: server + timeoutInMinutes: 1440 + + steps: + - task: ManualValidation@0 + displayName: Wait 24 hours for validation + inputs: + instructions: Please validate the release + timeoutInMinutes: 1440 + + - job: publish + dependsOn: validation + displayName: Publish to PSGallery + pool: + type: release + os: windows + templateContext: + inputs: + - input: pipelineArtifact + artifactName: drop_buildstage_nupkg + variables: + - name: ob_outputDirectory + value: '$(Build.ArtifactStagingDirectory)/ONEBRANCH_ARTIFACT' + # Disable SBOM, signing, and codeQL for this job + - name: ob_sdl_sbom_enabled + value: false + - name: ob_signing_setup_enabled + value: false + - name: ob_sdl_codeql_compiled_enabled + value: false + + steps: + - task: PowerShell@2 + inputs: + targetType: 'inline' + script: | + Get-ChildItem $(Pipeline.Workspace) -Recurse | Out-String -Width 120 -Stream + displayName: Find signed Nupkg + + - task: NuGetCommand@2 + condition: ${{ parameters.Release }} + displayName: Push PSReadLine module to PSGallery feed + inputs: + command: push + packagesToPush: $(Pipeline.Workspace)\PSReadLine.*.nupkg + nuGetFeedType: external + publishFeedCredentials: PowerShellGalleryFeed diff --git a/.vscode/launch.json b/.vscode/launch.json index 2d929fb4b..f15ddc52f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,7 +4,24 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - + { + "name": "Launch PSReadLine", + "type": "coreclr", + "request": "launch", + "program": "pwsh", + "args": [ + "-NonInteractive", + "-NoProfile", + "-NoExit", + "-Command", + "Import-Module '${workspaceFolder}/PSReadLine/bin/Debug/net8.0/PSReadLine.psd1'" + ], + "console": "integratedTerminal", + "justMyCode": false, + "suppressJITOptimizations": true, + "enableStepFiltering": false, + "preLaunchTask": "Build", + }, { "name": ".NET Core Attach", "type": "coreclr", @@ -16,4 +33,4 @@ } } ] -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..4393c8736 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,62 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Bootstrap", + "type": "shell", + "command": "pwsh", + "args": [ + "./build.ps1", + "-Bootstrap" + ], + "group": "build", + "detail": "Install build prerequisites (InvokeBuild, .NET SDK)" + }, + { + "label": "Build", + "type": "shell", + "command": "pwsh", + "args": [ + "./build.ps1" + ], + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [ + "$msCompile" + ], + "detail": "Build with user-selected configuration" + }, + { + "label": "Run Tests", + "type": "shell", + "command": "pwsh", + "args": [ + "./build.ps1", + "-Test" + ], + "group": { + "kind": "test", + "isDefault": true + }, + "presentation": { + "focus": true, + "panel": "dedicated", + "clear": true + }, + "detail": "Run unit tests" + }, + { + "label": "Clean", + "type": "shell", + "command": "pwsh", + "args": [ + "./build.ps1", + "-Clean" + ], + "group": "build", + "detail": "Clean build artifacts" + } + ] +} diff --git a/.vsts-ci/releaseBuild.yml b/.vsts-ci/releaseBuild.yml deleted file mode 100644 index e30050035..000000000 --- a/.vsts-ci/releaseBuild.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: PSReadLine-ModuleBuild-$(Build.BuildId) -trigger: none -pr: none - -variables: - DOTNET_CLI_TELEMETRY_OPTOUT: 1 - POWERSHELL_TELEMETRY_OPTOUT: 1 - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 - SBOMGenerator_Formats: 'spdx:2.2' - -resources: - repositories: - - repository: ComplianceRepo - type: github - endpoint: ComplianceGHRepo - name: PowerShell/compliance - -stages: -- stage: Build - displayName: Build and Sign - pool: - name: PowerShell1ES - demands: - - ImageOverride -equals PSMMS2019-Secure - jobs: - - job: build_windows - displayName: Build PSReadLine - variables: - - group: ESRP - - steps: - - - checkout: self - clean: true - persistCredentials: true - - - pwsh: | - function Send-VstsCommand ($vstsCommandString) { - Write-Host ("sending: " + $vstsCommandString) - Write-Host "##$vstsCommandString" - } - Write-Host "PS Version: $($($PSVersionTable.PSVersion))" - Set-Location -Path '$(Build.SourcesDirectory)\PSReadLine' - .\build.ps1 -Bootstrap - .\build.ps1 -Configuration Release -Framework net462 -CheckHelpContent - - # Set target folder paths - New-Item -Path .\bin\Release\NuGetPackage -ItemType Directory > $null - Send-VstsCommand "vso[task.setvariable variable=NuGetPackage]$(Build.SourcesDirectory)\PSReadLine\bin\Release\NuGetPackage" - Send-VstsCommand "vso[task.setvariable variable=PSReadLine]$(Build.SourcesDirectory)\PSReadLine\bin\Release\PSReadLine" - Send-VstsCommand "vso[task.setvariable variable=Signed]$(Build.SourcesDirectory)\PSReadLine\bin\Release\Signed" - displayName: Bootstrap & Build - - - task: ms.vss-governance-buildtask.governance-build-task-component-detection.ComponentGovernanceComponentDetection@0 - displayName: 'Component Governance Detection' - inputs: - sourceScanPath: '$(Build.SourcesDirectory)\PSReadLine' - snapshotForceEnabled: true - scanType: 'Register' - failOnAlert: true - - - checkout: ComplianceRepo - - # Sign the module files - - template: EsrpSign.yml@ComplianceRepo - parameters: - # the folder which contains the binaries to sign - buildOutputPath: $(PSReadLine) - # the location to put the signed output - signOutputPath: $(Signed) - # the certificate ID to use - certificateId: "CP-230012" - pattern: | - *.psd1 - *.psm1 - *.ps1 - *.ps1xml - **\*.dll - !System.Runtime.InteropServices.RuntimeInformation.dll - !Microsoft.PowerShell.Pager.dll - useMinimatch: true - - # Replace the *.psm1, *.ps1, *.psd1, *.dll files with the signed ones - - pwsh: | - # Show the signed files - Get-ChildItem -Path $(Signed) - Copy-Item -Path $(Signed)\* -Destination $(PSReadLine) -Recurse -Force - displayName: 'Replace unsigned files with signed ones' - - # Verify the signatures - - pwsh: | - $HasInvalidFiles = $false - $WrongCert = @{} - Get-ChildItem -Path $(PSReadLine) -Recurse -Include "*.dll","*.ps*1*" | ` - Get-AuthenticodeSignature | ForEach-Object { - $_ | Select-Object Path, Status - if ($_.Status -ne 'Valid') { $HasInvalidFiles = $true } - if ($_.SignerCertificate.Subject -notmatch 'CN=Microsoft Corporation.*') { - $WrongCert.Add($_.Path, $_.SignerCertificate.Subject) - } - } - - if ($HasInvalidFiles) { throw "Authenticode verification failed. There is one or more invalid files." } - if ($WrongCert.Count -gt 0) { - $WrongCert - throw "Certificate should have the subject starts with 'Microsoft Corporation'" - } - displayName: 'Verify the signed files' - - # Generate a Software Bill of Materials (SBOM) - - template: Sbom.yml@ComplianceRepo - parameters: - BuildDropPath: '$(PSReadLine)' - Build_Repository_Uri: 'https://github.com/PowerShell/PSReadLine.git' - displayName: Generate SBOM - - - pwsh: | - try { - $RepoName = "PSRLLocal" - Register-PSRepository -Name $RepoName -SourceLocation $(NuGetPackage) -PublishLocation $(NuGetPackage) -InstallationPolicy Trusted - Publish-Module -Repository $RepoName -Path $(PSReadLine) - } finally { - Unregister-PSRepository -Name $RepoName -ErrorAction SilentlyContinue - } - Get-ChildItem -Path $(NuGetPackage) - displayName: 'Create the NuGet package' - - - pwsh: | - Get-ChildItem -Path $(PSReadLine), $(NuGetPackage) - Write-Host "##vso[artifact.upload containerfolder=PSReadLine;artifactname=PSReadLine]$(PSReadLine)" - Write-Host "##vso[artifact.upload containerfolder=NuGetPackage;artifactname=NuGetPackage]$(NuGetPackage)" - displayName: 'Upload artifacts' - -- stage: compliance - displayName: Compliance - dependsOn: Build - pool: - name: PowerShell1ES - demands: - - ImageOverride -equals PSMMS2019-Secure - jobs: - - job: Compliance_Job - displayName: PSReadLine Compliance - variables: - - group: APIScan - # APIScan can take a long time - timeoutInMinutes: 240 - - steps: - - checkout: self - - checkout: ComplianceRepo - - download: current - artifact: PSReadLine - - - pwsh: | - Get-ChildItem -Path "$(Pipeline.Workspace)\PSReadLine" -Recurse - displayName: Capture downloaded artifacts - - - pwsh: | - function Send-VstsCommand ($vstsCommandString) { - Write-Host ("sending: " + $vstsCommandString) - Write-Host "##$vstsCommandString" - } - - # Get module version - $psd1Data = Import-PowerShellDataFile -Path "$(Pipeline.Workspace)\PSReadLine\PSReadLine.psd1" - $moduleVersion = $psd1Data.ModuleVersion - $prerelease = $psd1Data.PrivateData.PSData.Prerelease - if ($prerelease) { $moduleVersion = "$moduleVersion-$prerelease" } - Send-VstsCommand "vso[task.setvariable variable=ModuleVersion]$moduleVersion" - displayName: Get Module Version - - - template: assembly-module-compliance.yml@ComplianceRepo - parameters: - # binskim - AnalyzeTarget: '$(Pipeline.Workspace)\PSReadLine\*.dll' - AnalyzeSymPath: 'SRV*' - # component-governance - sourceScanPath: '' - # credscan - suppressionsFile: '' - # TermCheck - optionsRulesDBPath: '' - optionsFTPath: '' - # tsa-upload - codeBaseName: 'PSReadLine_201912' - # apiscan - softwareFolder: '$(Pipeline.Workspace)\PSReadLine' - softwareName: 'PSReadLine' - softwareVersion: '$(ModuleVersion)' - connectionString: 'RunAs=App;AppId=$(APIScanClient);TenantId=$(APIScanTenant);AppKey=$(APIScanSecret)' diff --git a/MockPSConsole/MockPSConsole.csproj b/MockPSConsole/MockPSConsole.csproj index e934b2446..d0f91fa71 100644 --- a/MockPSConsole/MockPSConsole.csproj +++ b/MockPSConsole/MockPSConsole.csproj @@ -4,21 +4,14 @@ Exe MockPSConsole MockPSConsole - net462;net6.0 + net8.0 512 Program.manifest true - - - - - - - - - + + diff --git a/PSReadLine.build.ps1 b/PSReadLine.build.ps1 index 7cb98f2da..4d54be206 100644 --- a/PSReadLine.build.ps1 +++ b/PSReadLine.build.ps1 @@ -19,82 +19,60 @@ param( [ValidateSet("Debug", "Release")] [string]$Configuration = (property Configuration Release), - [ValidateSet("net462", "net6.0")] - [string]$Framework, - [switch]$CheckHelpContent ) Import-Module "$PSScriptRoot/tools/helper.psm1" -# Final bits to release go here -$targetDir = "bin/$Configuration/PSReadLine" +# Dynamically read target framework from project file +$csprojPath = "$PSScriptRoot/PSReadLine/PSReadLine.csproj" +[xml]$csproj = Get-Content $csprojPath +$targetFramework = $csproj.Project.PropertyGroup.TargetFramework | Where-Object { $_ } | Select-Object -First 1 -if (-not $Framework) -{ - $Framework = if ($PSVersionTable.PSEdition -eq "Core") { "net6.0" } else { "net462" } +if (-not $targetFramework) { + throw "Could not determine TargetFramework from $csprojPath" } -Write-Verbose "Building for '$Framework'" -Verbose +Write-Verbose "Target framework: $targetFramework" + +# Final bits to release go here +$targetDir = "bin/$Configuration/PSReadLine" function ConvertTo-CRLF([string] $text) { $text.Replace("`r`n","`n").Replace("`n","`r`n") } -$polyFillerParams = @{ - Inputs = { Get-ChildItem Polyfill/*.cs, Polyfill/Polyfill.csproj } - Outputs = "Polyfill/bin/$Configuration/$Framework/Microsoft.PowerShell.PSReadLine.Polyfiller.dll" -} - $binaryModuleParams = @{ - Inputs = { Get-ChildItem PSReadLine/*.cs, PSReadLine/PSReadLine.csproj, PSReadLine/PSReadLineResources.resx, Polyfill/*.cs, Polyfill/Polyfill.csproj } - Outputs = "PSReadLine/bin/$Configuration/$Framework/Microsoft.PowerShell.PSReadLine2.dll" + Inputs = { Get-ChildItem PSReadLine/*.cs, PSReadLine/PSReadLine.csproj, PSReadLine/PSReadLineResources.resx } + Outputs = "PSReadLine/bin/$Configuration/$targetFramework/Microsoft.PowerShell.PSReadLine.dll" } $xUnitTestParams = @{ Inputs = { Get-ChildItem test/*.cs, test/*.json, test/PSReadLine.Tests.csproj } - Outputs = "test/bin/$Configuration/$Framework/PSReadLine.Tests.dll" -} - -$mockPSConsoleParams = @{ - Inputs = { Get-ChildItem MockPSConsole/*.cs, MockPSConsole/Program.manifest, MockPSConsole/MockPSConsole.csproj } - Outputs = "MockPSConsole/bin/$Configuration/$Framework/MockPSConsole.dll" -} - -<# -Synopsis: Build the Polyfiller assembly -#> -task BuildPolyfiller @polyFillerParams -If ($Framework -eq "net462") { - ## Build both "net462" and "net6.0" - exec { dotnet publish -f "net462" -c $Configuration Polyfill } - exec { dotnet publish -f "net6.0" -c $Configuration Polyfill } + Outputs = "test/bin/$Configuration/$targetFramework/PSReadLine.Tests.dll" } <# Synopsis: Build main binary module #> task BuildMainModule @binaryModuleParams { - exec { dotnet publish -f $Framework -c $Configuration PSReadLine } + exec { dotnet publish -c $Configuration PSReadLine\PSReadLine.csproj } } <# Synopsis: Build xUnit tests #> task BuildXUnitTests @xUnitTestParams { - exec { dotnet publish -f $Framework -c $Configuration test } -} - -<# -Synopsis: Build the mock powershell console. -#> -task BuildMockPSConsole @mockPSConsoleParams { - exec { dotnet publish -f $Framework -c $Configuration MockPSConsole } + exec { dotnet publish -f $targetFramework -c $Configuration test } } <# Synopsis: Run the unit tests #> -task RunTests BuildMainModule, BuildXUnitTests, { Start-TestRun -Configuration $Configuration -Framework $Framework } +task RunTests BuildMainModule, BuildXUnitTests, { + Write-Verbose "Run tests targeting $targetFramework ..." + Start-TestRun -Configuration $Configuration -Framework $targetFramework +} <# Synopsis: Check if the help content is in sync. @@ -110,7 +88,7 @@ task CheckHelpContent -If $CheckHelpContent { <# Synopsis: Copy all of the files that belong in the module to one place in the layout for installation #> -task LayoutModule BuildPolyfiller, BuildMainModule, { +task LayoutModule BuildMainModule, { if (-not (Test-Path $targetDir -PathType Container)) { New-Item $targetDir -ItemType Directory -Force > $null } @@ -128,47 +106,34 @@ task LayoutModule BuildPolyfiller, BuildMainModule, { Set-Content -Path (Join-Path $targetDir (Split-Path $file -Leaf)) -Value (ConvertTo-CRLF $content) -Force } - if ($Framework -eq "net462") { - if (-not (Test-Path "$targetDir/net462")) { - New-Item "$targetDir/net462" -ItemType Directory -Force > $null - } - if (-not (Test-Path "$targetDir/net6plus")) { - New-Item "$targetDir/net6plus" -ItemType Directory -Force > $null - } - - Copy-Item "Polyfill/bin/$Configuration/net462/Microsoft.PowerShell.PSReadLine.Polyfiller.dll" "$targetDir/net462" -Force - Copy-Item "Polyfill/bin/$Configuration/net6.0/Microsoft.PowerShell.PSReadLine.Polyfiller.dll" "$targetDir/net6plus" -Force - } - - $binPath = "PSReadLine/bin/$Configuration/$Framework/publish" - Copy-Item $binPath/Microsoft.PowerShell.PSReadLine2.dll $targetDir + $binPath = "PSReadLine/bin/$Configuration/$targetFramework/publish" + Copy-Item $binPath/Microsoft.PowerShell.PSReadLine.dll $targetDir Copy-Item $binPath/Microsoft.PowerShell.Pager.dll $targetDir if ($Configuration -eq 'Debug') { Copy-Item $binPath/*.pdb $targetDir } - if (Test-Path $binPath/System.Runtime.InteropServices.RuntimeInformation.dll) { - Copy-Item $binPath/System.Runtime.InteropServices.RuntimeInformation.dll $targetDir - } else { - Write-Warning "Build using $Framework is not sufficient to be downlevel compatible" - } - # Copy module manifest, but fix the version to match what we've specified in the binary module. $moduleManifestContent = ConvertTo-CRLF (Get-Content -Path 'PSReadLine/PSReadLine.psd1' -Raw) - $versionInfo = (Get-ChildItem -Path $targetDir/Microsoft.PowerShell.PSReadLine2.dll).VersionInfo + $versionInfo = (Get-ChildItem -Path $targetDir/Microsoft.PowerShell.PSReadLine.dll).VersionInfo $version = $versionInfo.FileVersion $semVer = $versionInfo.ProductVersion - if ($semVer -match "(.*)-(.*)") { + # dotnet build may add the Git commit hash to the 'ProductVersion' attribute with this format: +. + if ($semVer -match "(.*)-([^\+]*)(?:\+.*)?") { # Make sure versions match if ($matches[1] -ne $version) { throw "AssemblyFileVersion mismatch with AssemblyInformationalVersion" } $prerelease = $matches[2] - # Put the prerelease tag in private data - $moduleManifestContent = [regex]::Replace($moduleManifestContent, "}", "PrivateData = @{ PSData = @{ Prerelease = '$prerelease'; ProjectUri = 'https://github.com/PowerShell/PSReadLine' } }$([System.Environment]::Newline)}") + # Put the prerelease tag in private data, along with the project URI. + $privateDataSection = "PrivateData = @{ PSData = @{ Prerelease = '$prerelease'; ProjectUri = 'https://github.com/PowerShell/PSReadLine' } }" + } else { + # Put the project URI in private data. + $privateDataSection = "PrivateData = @{ PSData = @{ ProjectUri = 'https://github.com/PowerShell/PSReadLine' } }" } + $moduleManifestContent = [regex]::Replace($moduleManifestContent, "}", "${privateDataSection}$([System.Environment]::Newline)}") $moduleManifestContent = [regex]::Replace($moduleManifestContent, "ModuleVersion = '.*'", "ModuleVersion = '$version'") $moduleManifestContent | Set-Content -Path $targetDir/PSReadLine.psd1 diff --git a/PSReadLine/Accessibility.cs b/PSReadLine/Accessibility.cs index 4938da233..aae6c0f52 100644 --- a/PSReadLine/Accessibility.cs +++ b/PSReadLine/Accessibility.cs @@ -2,6 +2,7 @@ Copyright (c) Microsoft Corporation. All rights reserved. --********************************************************************/ +using System.Diagnostics; using System.Runtime.InteropServices; namespace Microsoft.PowerShell.Internal @@ -10,14 +11,82 @@ internal class Accessibility { internal static bool IsScreenReaderActive() { - bool returnValue = false; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - PlatformWindows.SystemParametersInfo(PlatformWindows.SPI_GETSCREENREADER, 0, ref returnValue, 0); + return IsAnyWindowsScreenReaderEnabled(); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return IsVoiceOverEnabled(); + } + + // TODO: Support Linux per https://code.visualstudio.com/docs/configure/accessibility/accessibility + return false; + } + + private static bool IsAnyWindowsScreenReaderEnabled() + { + // The supposedly official way to check for a screen reader on + // Windows is SystemParametersInfo(SPI_GETSCREENREADER, ...) but it + // doesn't detect the in-box Windows Narrator and is otherwise known + // to be problematic. + // + // Unfortunately, the alternative method used by Electron and + // Chromium, where the relevant screen reader libraries (modules) + // are checked for does not work in the context of PowerShell + // because it relies on those applications injecting themselves into + // the app. Which they do not because PowerShell is not a windowed + // app, so we're stuck using the known-to-be-buggy way. + bool spiScreenReader = false; + PlatformWindows.SystemParametersInfo(PlatformWindows.SPI_GETSCREENREADER, 0, ref spiScreenReader, 0); + if (spiScreenReader) + { + return true; + } + + // At least we can correctly check for Windows Narrator using the + // NarratorRunning mutex. Windows Narrator is mostly not broken with + // PSReadLine, not in the way that NVDA and VoiceOver are. + if (PlatformWindows.IsMutexPresent("NarratorRunning")) + { + return true; + } + + return false; + } + + private static bool IsVoiceOverEnabled() + { + try + { + // Use the 'defaults' command to check if VoiceOver is enabled + // This checks the com.apple.universalaccess preference for voiceOverOnOffKey + ProcessStartInfo startInfo = new() + { + FileName = "defaults", + Arguments = "read com.apple.universalaccess voiceOverOnOffKey", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + + using Process process = Process.Start(startInfo); + process.WaitForExit(250); + if (process.HasExited && process.ExitCode == 0) + { + string output = process.StandardOutput.ReadToEnd().Trim(); + // VoiceOver is enabled if the value is 1 + return output == "1"; + } + } + catch + { + // If we can't determine the status, assume VoiceOver is not enabled } - return returnValue; + return false; } } } diff --git a/PSReadLine/BasicEditing.cs b/PSReadLine/BasicEditing.cs index a59bdf4ef..74c76ac00 100644 --- a/PSReadLine/BasicEditing.cs +++ b/PSReadLine/BasicEditing.cs @@ -86,7 +86,7 @@ public static void CancelLine(ConsoleKeyInfo? key = null, object arg = null) _singleton._current = _singleton._buffer.Length; using var _ = _singleton._prediction.DisableScoped(); - _singleton.ForceRender(); + _singleton.Render(force: true); _singleton._console.Write("\x1b[91m^C\x1b[0m"); @@ -246,6 +246,72 @@ public static void DeleteCharOrExit(ConsoleKeyInfo? key = null, object arg = nul _singleton.DeleteCharImpl(1, orExit: true); } + /// + /// A helper function to change the case of the current word. + /// + private static void UpdateWordCase(bool toUpper) + { + if (_singleton._current >= _singleton._buffer.Length) + { + Ding(); + return; + } + + int endOfWord = _singleton.FindForwardWordPoint(_singleton.Options.WordDelimiters); + int wordlen = endOfWord - _singleton._current; + + string word = _singleton._buffer.ToString(_singleton._current, wordlen); + word = toUpper ? word.ToUpper() : word.ToLower(); + + Replace(_singleton._current, wordlen, word); + + _singleton.MoveCursor(endOfWord); + _singleton.Render(); + } + + /// + /// Upcase the current word and move to the next one. + /// + public static void UpcaseWord(ConsoleKeyInfo? key = null, object arg = null) + { + UpdateWordCase(toUpper: true); + } + + /// + /// Downcase the current word and move to the next one. + /// + public static void DowncaseWord(ConsoleKeyInfo? key = null, object arg = null) + { + UpdateWordCase(toUpper: false); + } + + /// + /// Capitalize the current word and move to the next one. + /// + public static void CapitalizeWord(ConsoleKeyInfo? key = null, object arg = null) + { + if (_singleton._current >= _singleton._buffer.Length) + { + Ding(); + return; + } + + int endOfWord = _singleton.FindForwardWordPoint(_singleton.Options.WordDelimiters); + int wordlen = endOfWord - _singleton._current; + + char[] word = _singleton._buffer.ToString(_singleton._current, wordlen).ToLower().ToCharArray(); + int firstLetterIdx = Array.FindIndex(word, static x => char.IsLetter(x)); + + if (firstLetterIdx >= 0) + { + word[firstLetterIdx] = char.ToUpper(word[firstLetterIdx]); + Replace(_singleton._current, wordlen, new string(word)); + } + + _singleton.MoveCursor(endOfWord); + _singleton.Render(); + } + private bool AcceptLineImpl(bool validate) { using var _ = _prediction.DisableScoped(); @@ -269,7 +335,7 @@ private bool AcceptLineImpl(bool validate) if (renderNeeded) { - ForceRender(); + Render(force: true); } // Only run validation if we haven't before. If we have and status line shows an error, diff --git a/PSReadLine/Changes.txt b/PSReadLine/Changes.txt index 1ebef1056..0b99add82 100644 --- a/PSReadLine/Changes.txt +++ b/PSReadLine/Changes.txt @@ -1,3 +1,190 @@ +### [2.4.5] - 2025-10-22 + +- Replace `DOTNET_SKIP_FIRST_TIME_EXPERIENCE` with `DOTNET_NOLOGO` (#4916) (Thanks @xtqqczze!) +- Add the `ScreenReaderModeEnabled` property to formatting (#4970) +- Fix a null reference exception when showing parameter help (#4971) + +[2.4.5]: https://github.com/PowerShell/PSReadLine/compare/v2.4.4-beta4...v2.4.5 + +### [2.4.4-beta4] - 2025-08-28 + +- Fix `IsMutexPresent()` to avoid incorrect use of `GetLastWin32Error()` (#4910) +- Add screen reader support to PSReadLine (#4854) + +[2.4.4-beta4]: https://github.com/PowerShell/PSReadLine/compare/v2.4.3-beta3...v2.4.4-beta4 + +### [2.4.3-beta3] - 2025-07-23 + +- Allow accepting the current input automatically from within an `OnIdle` event handler (#4830) +- Add VS Code tasks and debug config (#4834, #4855) +- Add bound check for the cursor top value to `InvokePrompt` (#4791) (Thanks @jftkcs!) +- Fix typo in `SamplePSReadLineProfile.ps1` (#4725) (Thanks @mahir-cadirci!) +- Fix line ending and cache some reflection operations (#4709) +- Improve test reliability by making sure the PSReadLine one-time initialization is done (#4686) (Thanks @springcomp!) + +[2.4.3-beta3]: https://github.com/PowerShell/PSReadLine/compare/v2.4.2-beta2...v2.4.3-beta3 + +### [2.4.2-beta2] - 2025-04-16 + +- Add a private field to indicate if PSReadLine is initialized and ready (#4706) +- Use CFS for installing module and deploy box for module publish (#4700) + +[2.4.2-beta2]: https://github.com/PowerShell/PSReadLine/compare/v2.4.1-beta1...v2.4.2-beta2 + +### [2.4.1-beta1] - 2025-02-28 + +#### Code Changes + +- Avoid querying for cursor position when it's not necessary (#4448) +- Handle buffer changes made by an event handler (#4442) +- Update `SelectCommandArgument` to properly handle POSIX style options for CLI commands (#4016) + +#### Build Changes + +- Update PSReadLine build to target `netstandard2.0` (#4584) +- Update documentation about the building of PSReadLine (#4286) (Thanks @sangafabrice!) +- Update `HelpInfoUri` for 7.5 (#4284) +- Update the release pipeline to remove `AzFeed` from display name (#4204) +- Update the OneBranch pipeline to keep it compliant and remove SBOM files from module (#4201) +- Make sure the `CodeQL` result from release pipeline gets uploaded (#4082) +- Add 'ob_restore_phase' for every task before the signing task to work around the signing issue (#4046) +- Change the NuGet feed to use the governed PowerShell feed (#4044) +- Update "Code of Conduct" and "Security Policy" (#4037) +- Disable SBOM, signing, and codeQL for the publish job (#3986) +- Update triage messages to use the latest stable version (#3985) +- Fix the release stage and update the changelog for v2.3.5 servicing release (#3984) +- Add the release stage to the pipeline and exclude test folders from Component Governance (#3982) +- Change back to 'external_distribution' for nupkg signing (#3977) +- Migrate PSReadLine release build pipeline to OneBranch (#3975) + +[2.4.1-beta1]: https://github.com/PowerShell/PSReadLine/compare/v2.4.0-beta0...v2.4.1-beta1 + +### [2.4.0-beta0] - 2024-03-01 + +- Fix the null-reference exception when running `Debug-Job` on a thread job (#3957) +- Add needed permission to the workflow (#3944, #3945, #3946) +- Fix copying text to system clipboard on Linux using xclip (#3937) +- Use the correct directory separator for tab completion based on the platform we are working with (#3935) +- Update the minimal PS version required to be 5.1 (#3936) +- Little code style cleanup for the `GetCompletions()` method (#3898) +- Stop trying to de-duplicate completion results (#3897) +- Windows keyboard layout handling: get the current layout from the parent terminal process (#3786) (Thanks @ForNeVeR!) +- Add "resolution no activity" label to the bot-close list (#3852) +- Fix a few VI key handlers to close edit group properly (#3845) +- Update the documentation issue template to point to the PowerShell-Doc repo (#3839, #3840, #3841) +- Handle large history file properly by reading lines in the streaming way (#3810) +- Update build script to always include the `ProjectUri` info (#3821) + +[2.4.0-beta0]: https://github.com/PowerShell/PSReadLine/compare/v2.3.4...v2.4.0-beta0 + +### [2.3.6] - 2024-10-02 + +This is a servicing release that excludes SBOM files from the module. + +- Update the OneBranch pipeline to keep it compliant and remove SBOM files from module (#4201) +- Make sure the `CodeQL` result from release pipeline gets uploaded (#4082) +- Add 'ob_restore_phase' for every task before the signing task to work around the signing issue (#4046) +- Change the NuGet feed to use the governed PowerShell feed (#4044) +- Disable SBOM, signing, and codeQL for the publish job (#3986) + +[2.3.6]: https://github.com/PowerShell/PSReadLine/compare/v2.3.5...v2.3.6 + +### [2.3.5] - 2024-04-02 + +This is a servicing release that excludes test components from SBOM generation. + +- Add the release stage to the pipeline and exclude test folders from Component Governance (#3982) +- Change back to 'external_distribution' for nupkg signing (#3977) +- Migrate PSReadLine release build pipeline to OneBranch (#3975) +- Fix the null-reference exception when running `Debug-Job` on a thread job (#3957) +- Update build script to always include the `ProjectUri` info (#3821) + +[2.3.5]: https://github.com/PowerShell/PSReadLine/compare/v2.3.4...v2.3.5 + +### [2.3.4] - 2023-10-02 + +- Choose the inline prediction color based on the environment (#3808) +- Update the stable version of PSReadLine used in the auto triage messages (#3804) +- Update `Compliance_Job` to add a new variable group for APIScan (#3803) + +[2.3.4]: https://github.com/PowerShell/PSReadLine/compare/v2.3.3...v2.3.4 + +### [2.3.3] - 2023-09-18 + +- Re-package the `2.3.2-beta2` version to `2.3.3` as an official stable release. + +[2.3.3]: https://github.com/PowerShell/PSReadLine/compare/v2.3.2-beta2...v2.3.3 + +### [2.3.2-beta2] - 2023-08-17 + +- Work around `InvalidOperationException` from Console API (#3755) (Thanks @jazzdelightsme!) +- Add the `TerminateOrphanedConsoleApps` option on Windows to kill orphaned console-attached process that may mess up reading from Console input (#3764) (Thanks @jazzdelightsme!) +- Fix bot to add `needs-triage` label to newly opened issue (#3772) +- Update `actions/checkout` used in GitHub action to v3 (#3773) +- Supports the text-object command `diw` in the VI edit mode (#2059) (Thanks @springcomp!) +- Fix `NullReferenceException` when processing event subscribers (#3781) +- Point to `F7History` in the comment of the `F7` key-binding sample (#3782) + +[2.3.2-beta2]: https://github.com/PowerShell/PSReadLine/compare/v2.3.1-beta1...v2.3.2-beta2 + +### [2.3.1-beta1] - 2023-05-03 + +- Append reset VT sequence before rendering the ineline prediction (#3669) +- Support tooltip rendering in the prediction list view (#3667, #3671) +- Fix the broken doc link about `PowerShellGet` (#3657) (Thanks @vimode!) +- Add a sample for transforming Unicode code point to Unicode char by `Alt+x` (#3652) +- Avoid running `AddToHistoryHandler` on command lines loaded from history file (#3643) +- Force refreshing suggestion in the inline view when plugin is in use (#3644) +- Improve the sensitive history scrubbing to allow retrieving token from `az`, `gcloud`, and `kubectl` (#3641) +- Set the current location in `PredictionClient` when it's supported (#3639) +- Improve the default sensitive history scrubbing to allow safe property access (#3630) +- Make PSReadLine script hidden from debugger (#3629) + +[2.3.1-beta1]: https://github.com/PowerShell/PSReadLine/compare/v2.3.0-beta0...v2.3.1-beta1 + +### [2.3.0-beta0] - 2023-03-07 + +- Fix the menu completion to better handle the backspace key (#3574) +- Improve the list view to be scrollable and auto-adjust the list view height (#3583) +- Use 'Visual Studio 2022' as the image for `appveyor` CI (#3594) +- Fix some typos in this repository (#3547) (Thanks @spaette!) +- De-duplicate prediction results with the history results (#3543) +- Updating Fabric bot (#3540, #3576) +- Change default color for inline prediction to `dim` (#3493) +- Make tab completion show results whose `ListItemText` are different by case only (#3456) (Thanks @dkaszews!) +- Fix to use the default member color for members (#3450) +- Update the samples in README.md (#3440, #3424) +- Place 'ViDGChord' in the right group (#3422) +- Fix the description of `CapitalizeWord` (#3384) +- Add support for upcasing, downcasing, and capitalizing word (#3365) (Thanks @3N4N!) +- No list view prediction when the first line was scrolled up off the buffer (#3372) +- Fix wrong cursor position in menu completion (#3373) +- Fix `ViModeIndicator = Cursor` for Windows Terminal (#3374) +- Fix parameter dynamic help when the help content is specified in ParameterAttribute (#3370) +- Handle multi-line description for parameter help content (#3358) +- Update module version in bot messages (#3361) + +[2.3.0-beta0]: https://github.com/PowerShell/PSReadLine/compare/v2.2.6...v2.3.0-beta0 + +### [2.2.6] - 2022-06-27 + +- Enable Predictive Intellisense by default (#3351) + +[2.2.6]: https://github.com/PowerShell/PSReadLine/compare/v2.2.5...v2.2.6 + +### [2.2.5] - 2022-05-03 + +- Re-package the `2.2.4-beta1` version to `2.2.5` as an official servicing release. + +[2.2.5]: https://github.com/PowerShell/PSReadLine/compare/v2.2.4-beta1...v2.2.5 + +### [2.2.4-beta1] - 2022-04-27 + +- Sync ReadKeyProc thread with pipeline thread (#3294) +- Update build to use net462 (#3285) + +[2.2.4-beta1]: https://github.com/PowerShell/PSReadLine/compare/v2.2.3...v2.2.4-beta1 + ### [2.2.3] - 2022-04-20 - Respect cancellation in `ReadOneOrMoreKeys()` (#3274, #3280) @@ -366,7 +553,7 @@ Bug fixes: * Fix InvokePrompt when the prompt is > 1 line. * Fix YankToPercent off by 1 error. * Fix error reported when running in container. -* Catch and ignore execptions in InvokePrompt (#583) +* Catch and ignore exceptions in InvokePrompt (#583) * Get new completions on 2nd tab if 1st had 1 result (#238) * Tab replaced with 4 spaces during paste (#144) * Fix rendering after buffer resize (#418) @@ -651,7 +838,7 @@ New features: * Add ETW event source for demo mode, key logger, macro recorder etc. * Undo/redo * Get-PSReadLineOption cmdlet -* Make specifying key handlers for builtins simpler +* Make specifying key handlers for built-ins simpler * Current un-entered line is saved and recalled when cycling through history * Support syntax coloring of member names diff --git a/PSReadLine/Clipboard.cs b/PSReadLine/Clipboard.cs index a91586b9f..60e6d9392 100644 --- a/PSReadLine/Clipboard.cs +++ b/PSReadLine/Clipboard.cs @@ -16,22 +16,22 @@ static class Clipboard // This is useful for testing in CI as well. private static string _internalClipboard; - private static string StartProcess( - string tool, - string args, - string stdin = "" - ) + private static string StartProcess(bool collectOutput, string tool, string args, string stdin = null) { - ProcessStartInfo startInfo = new ProcessStartInfo(); - startInfo.UseShellExecute = false; - startInfo.RedirectStandardInput = true; - startInfo.RedirectStandardOutput = true; - startInfo.RedirectStandardError = true; - startInfo.FileName = tool; - startInfo.Arguments = args; string stdout; + bool redirectInput = !string.IsNullOrEmpty(stdin); - using (Process process = new Process()) + ProcessStartInfo startInfo = new() + { + UseShellExecute = false, + RedirectStandardInput = redirectInput, + RedirectStandardOutput = true, + RedirectStandardError = true, + FileName = tool, + Arguments = args + }; + + using (Process process = new()) { process.StartInfo = startInfo; try @@ -42,15 +42,16 @@ private static string StartProcess( { _clipboardSupported = false; PSConsoleReadLine.Ding(); - return ""; + return string.Empty; } - if (stdin != "") + if (redirectInput) { process.StandardInput.Write(stdin); process.StandardInput.Close(); } - stdout = process.StandardOutput.ReadToEnd(); + + stdout = collectOutput ? process.StandardOutput.ReadToEnd() : string.Empty; process.WaitForExit(250); _clipboardSupported = process.ExitCode == 0; @@ -91,7 +92,7 @@ public static string GetText() return ""; } - return StartProcess(tool, args); + return StartProcess(collectOutput: true, tool, args); } public static void SetText(string text) @@ -128,7 +129,7 @@ public static void SetText(string text) return; } - StartProcess(tool, args, text); + StartProcess(collectOutput: false, tool, args, text); if (_clipboardSupported == false) { _internalClipboard = text; diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs index 9aa051e46..596b1548e 100644 --- a/PSReadLine/Cmdlets.cs +++ b/PSReadLine/Cmdlets.cs @@ -15,6 +15,7 @@ using System.Runtime.InteropServices; using System.Threading; using Microsoft.PowerShell.PSReadLine; +using Microsoft.PowerShell.Internal; using AllowNull = System.Management.Automation.AllowNullAttribute; namespace Microsoft.PowerShell @@ -95,16 +96,14 @@ public class PSConsoleReadLineOptions // Find the most suitable color using https://stackoverflow.com/a/33206814 // Default prediction color settings: - // - use FG color 'dark black' for the inline-view suggestion text // - use FG color 'yellow' for the list-view suggestion text // - use BG color 'dark black' for the selected list-view suggestion text - public const string DefaultInlinePredictionColor = "\x1b[38;5;238m"; public const string DefaultListPredictionColor = "\x1b[33m"; public const string DefaultListPredictionSelectedColor = "\x1b[48;5;238m"; - public static EditMode DefaultEditMode = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? EditMode.Windows - : EditMode.Emacs; + public static readonly string DefaultInlinePredictionColor; + public static readonly string DefaultListPredictionTooltipColor; + public static readonly EditMode DefaultEditMode; public const string DefaultContinuationPrompt = ">> "; @@ -141,7 +140,7 @@ public class PSConsoleReadLineOptions public const int DefaultCompletionQueryItems = 100; // Default includes all characters PowerShell treats like a dash - em dash, en dash, horizontal bar - public const string DefaultWordDelimiters = @";:,.[]{}()/\|^&*-=+'""" + "\u2013\u2014\u2015"; + public const string DefaultWordDelimiters = @";:,.[]{}()/\|!?^&*-=+'""" + "\u2013\u2014\u2015"; /// /// When ringing the bell, what should be done? @@ -152,11 +151,6 @@ public class PSConsoleReadLineOptions public const HistorySaveStyle DefaultHistorySaveStyle = HistorySaveStyle.SaveIncrementally; - /// - /// The predictive suggestion feature is disabled by default. - /// - public const PredictionSource DefaultPredictionSource = PredictionSource.None; - public const PredictionViewStyle DefaultPredictionViewStyle = PredictionViewStyle.InlineView; /// @@ -165,10 +159,45 @@ public class PSConsoleReadLineOptions /// public const int DefaultAnsiEscapeTimeout = 100; - public PSConsoleReadLineOptions(string hostName) + static PSConsoleReadLineOptions() + { + // For inline-view suggestion text, we use the new FG color 'dim white italic' when possible, because it provides + // sufficient contrast in terminals that don't use a pure black background (like VSCode terminal). + // However, on Windows 10 and Windows Server, the ConHost doesn't support font effect VT sequences, such as 'dim' + // and 'italic', so we need to use the old FG color 'dark black' as in the v2.2.6. + const string newInlinePredictionColor = "\x1b[97;2;3m"; + const string oldInlinePredictionColor = "\x1b[38;5;238m"; + + ColorSetters = null; + DefaultEditMode = EditMode.Emacs; + DefaultInlinePredictionColor = newInlinePredictionColor; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + DefaultEditMode = EditMode.Windows; + + // Our tests expect that the default inline-view color is set to the new color, so we configure + // the color based on system environment only if we are not in test runs. + if (AppDomain.CurrentDomain.FriendlyName is not "testhost") + { + DefaultInlinePredictionColor = + Environment.OSVersion.Version.Build >= 22621 // on Windows 11 22H2 or newer versions + || Environment.GetEnvironmentVariable("WT_SESSION") is not null // in Windows Terminal + ? newInlinePredictionColor + : oldInlinePredictionColor; + } + } + + // Use the same color for the list prediction tooltips. + DefaultListPredictionTooltipColor = DefaultInlinePredictionColor; + DefaultAddToHistoryHandler = s => PSConsoleReadLine.GetDefaultAddToHistoryOption(s); + } + + public PSConsoleReadLineOptions(string hostName, bool usingLegacyConsole) { ResetColors(); EditMode = DefaultEditMode; + ScreenReaderModeEnabled = Accessibility.IsScreenReaderActive(); ContinuationPrompt = DefaultContinuationPrompt; ContinuationPromptColor = Console.ForegroundColor; ExtraPromptLineCount = DefaultExtraPromptLineCount; @@ -185,7 +214,11 @@ public PSConsoleReadLineOptions(string hostName) HistorySearchCaseSensitive = DefaultHistorySearchCaseSensitive; HistorySaveStyle = DefaultHistorySaveStyle; AnsiEscapeTimeout = DefaultAnsiEscapeTimeout; - PredictionSource = DefaultPredictionSource; + PredictionSource = usingLegacyConsole + ? PredictionSource.None + : Environment.Version.Major < 6 + ? PredictionSource.History + : PredictionSource.HistoryAndPlugin; PredictionViewStyle = DefaultPredictionViewStyle; MaximumHistoryCount = 0; @@ -280,8 +313,7 @@ public object ContinuationPromptColor /// or added to memory only, or added to both memory and history file. /// public Func AddToHistoryHandler { get; set; } - public static readonly Func DefaultAddToHistoryHandler = - s => PSConsoleReadLine.GetDefaultAddToHistoryOption(s); + public static readonly Func DefaultAddToHistoryHandler; /// /// This handler is called from ValidateAndAcceptLine. @@ -300,7 +332,6 @@ public object ContinuationPromptColor /// odd things with script blocks, we create a white-list of commands /// that do invoke the script block - this covers the most useful cases. /// - [SuppressMessage("Microsoft.Usage", "CA2227:CollectionPropertiesShouldBeReadOnly")] public HashSet CommandsToValidateScriptBlockArguments { get; set; } /// @@ -491,6 +522,16 @@ public object ListPredictionSelectedColor set => _listPredictionSelectedColor = VTColorUtils.AsEscapeSequence(value); } + public object ListPredictionTooltipColor + { + get => _listPredictionTooltipColor; + set => _listPredictionTooltipColor = VTColorUtils.AsEscapeSequence(value); + } + + public bool TerminateOrphanedConsoleApps { get; set; } + + public bool ScreenReaderModeEnabled { get; set; } + internal string _defaultTokenColor; internal string _commentColor; internal string _keywordColor; @@ -508,6 +549,7 @@ public object ListPredictionSelectedColor internal string _inlinePredictionColor; internal string _listPredictionColor; internal string _listPredictionSelectedColor; + internal string _listPredictionTooltipColor; internal void ResetColors() { @@ -522,12 +564,13 @@ internal void ResetColors() ParameterColor = DefaultParameterColor; TypeColor = DefaultTypeColor; NumberColor = DefaultNumberColor; - MemberColor = DefaultNumberColor; + MemberColor = DefaultMemberColor; EmphasisColor = DefaultEmphasisColor; ErrorColor = DefaultErrorColor; InlinePredictionColor = DefaultInlinePredictionColor; ListPredictionColor = DefaultListPredictionColor; ListPredictionSelectedColor = DefaultListPredictionSelectedColor; + ListPredictionTooltipColor = DefaultListPredictionTooltipColor; var bg = Console.BackgroundColor; if (fg == VTColorUtils.UnknownColor || bg == VTColorUtils.UnknownColor) @@ -540,7 +583,7 @@ internal void ResetColors() SelectionColor = VTColorUtils.AsEscapeSequence(bg, fg); } - private static Dictionary> ColorSetters = null; + private static Dictionary> ColorSetters; internal void SetColor(string property, object value) { @@ -567,6 +610,7 @@ internal void SetColor(string property, object value) {"InlinePrediction", (o, v) => o.InlinePredictionColor = v}, {"ListPrediction", (o, v) => o.ListPredictionColor = v}, {"ListPredictionSelected", (o, v) => o.ListPredictionSelectedColor = v}, + {"ListPredictionTooltip", (o, v) => o.ListPredictionTooltipColor = v}, }; Interlocked.CompareExchange(ref ColorSetters, setters, null); @@ -794,6 +838,22 @@ public PredictionViewStyle PredictionViewStyle [Parameter] public Hashtable Colors { get; set; } + [Parameter] + public SwitchParameter TerminateOrphanedConsoleApps + { + get => _terminateOrphanedConsoleApps.GetValueOrDefault(); + set => _terminateOrphanedConsoleApps = value; + } + internal SwitchParameter? _terminateOrphanedConsoleApps; + + [Parameter] + public SwitchParameter EnableScreenReaderMode + { + get => _enableScreenReaderMode.GetValueOrDefault(); + set => _enableScreenReaderMode = value; + } + internal SwitchParameter? _enableScreenReaderMode; + [ExcludeFromCodeCoverage] protected override void EndProcessing() { @@ -806,7 +866,6 @@ public class ChangePSReadLineKeyHandlerCommandBase : PSCmdlet [Parameter(Position = 0, Mandatory = true)] [Alias("Key")] [ValidateNotNullOrEmpty] - [SuppressMessage("Microsoft.Performance", "CA1819:PropertiesShouldNotReturnArrays")] public string[] Chord { get; set; } [Parameter] @@ -849,7 +908,7 @@ public class SetPSReadLineKeyHandlerCommand : ChangePSReadLineKeyHandlerCommandB public string BriefDescription { get; set; } [Parameter(ParameterSetName = "ScriptBlock")] - [Alias("LongDescription")] // Alias to stay comptible with previous releases + [Alias("LongDescription")] // Alias to stay compatible with previous releases public string Description { get; set; } private const string FunctionParameter = "Function"; @@ -879,8 +938,7 @@ protected override void EndProcessing() } } - private readonly Lazy _dynamicParameters = - new Lazy(CreateDynamicParametersResult); + private readonly Lazy _dynamicParameters = new(CreateDynamicParametersResult); private static RuntimeDefinedParameterDictionary CreateDynamicParametersResult() { @@ -1003,7 +1061,7 @@ public static class VTColorUtils public const ConsoleColor UnknownColor = (ConsoleColor) (-1); private static readonly Dictionary ConsoleColors = - new Dictionary(StringComparer.OrdinalIgnoreCase) + new(StringComparer.OrdinalIgnoreCase) { {"Black", ConsoleColor.Black}, {"DarkBlue", ConsoleColor.DarkBlue}, diff --git a/PSReadLine/Completion.cs b/PSReadLine/Completion.cs index 6506f24c9..c6654f924 100644 --- a/PSReadLine/Completion.cs +++ b/PSReadLine/Completion.cs @@ -23,6 +23,7 @@ public partial class PSConsoleReadLine private int _tabCommandCount; private CommandCompletion _tabCompletions; private Runspace _runspace; + private string _directorySeparator; private static readonly Dictionary KeysEndingCompletion = new Dictionary @@ -40,7 +41,7 @@ public partial class PSConsoleReadLine private static readonly char[] EolChars = {'\r', '\n'}; // String helper for directory paths - private static readonly string DirectorySeparatorString = System.IO.Path.DirectorySeparatorChar.ToString(); + private static readonly string DefaultDirectorySeparator = System.IO.Path.DirectorySeparatorChar.ToString(); // Stub helper method so completion can be mocked [ExcludeFromCodeCoverage] @@ -205,7 +206,7 @@ private void CompleteImpl(bool menuSelect) if (InViInsertMode()) // must close out the current edit group before engaging menu completion { ViCommandMode(); - ViInsertWithAppend(); + ViInsertWithAppendImpl(); } // Do not show suggestion text during tab completion. @@ -281,36 +282,49 @@ private CommandCompletion GetCompletions() System.Management.Automation.PowerShell ps; if (!_mockableMethods.RunspaceIsRemote(_runspace)) { + _directorySeparator ??= DefaultDirectorySeparator; ps = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace); } else { + if (_directorySeparator is null) + { + // Use the default separator by default. + _directorySeparator = DefaultDirectorySeparator; + PSPrimitiveDictionary dict = _runspace.GetApplicationPrivateData(); + + if (dict["PSVersionTable"] is PSPrimitiveDictionary versionTable) + { + // If the 'Platform' key is available and its value is not 'Win*', then the server side is macOS or Linux. + // In that case, we use the forward slash '/' as the directory separator. + // Otherwise, the server side is Windows and we use the backward slash '\' instead. + _directorySeparator = versionTable["Platform"] is string platform && !platform.StartsWith("Win", StringComparison.Ordinal) ? "/" : @"\"; + } + } + ps = System.Management.Automation.PowerShell.Create(); ps.Runspace = _runspace; } - _tabCompletions = _mockableMethods.CompleteInput(_buffer.ToString(), _current, null, ps); - if (_tabCompletions.CompletionMatches.Count == 0) return null; + _tabCompletions = _mockableMethods.CompleteInput(_buffer.ToString(), _current, null, ps); + if (_tabCompletions.CompletionMatches.Count == 0) + { + return null; + } // Validate the replacement index/length - if we can't do // the replacement, we'll ignore the completions. var start = _tabCompletions.ReplacementIndex; var length = _tabCompletions.ReplacementLength; - if (start < 0 || start > _singleton._buffer.Length) return null; - if (length < 0 || length > (_singleton._buffer.Length - start)) return null; - if (_tabCompletions.CompletionMatches.Count > 1) + if (start < 0 || start > _singleton._buffer.Length) { - // Filter out apparent duplicates - var hashSet = new HashSet(StringComparer.OrdinalIgnoreCase); + return null; + } - foreach (var match in _tabCompletions.CompletionMatches.ToArray()) - { - if (!hashSet.Add(match.ListItemText)) - { - _tabCompletions.CompletionMatches.Remove(match); - } - } + if (length < 0 || length > (_singleton._buffer.Length - start)) + { + return null; } } catch (Exception) @@ -378,12 +392,12 @@ private void DoReplacementForCompletion(CompletionResult completionResult, Comma completions.ReplacementLength = replacementText.Length; } - private static string GetReplacementTextForDirectory(string replacementText, ref int cursorAdjustment) + private string GetReplacementTextForDirectory(string replacementText, ref int cursorAdjustment) { - if (!replacementText.EndsWith(DirectorySeparatorString , StringComparison.Ordinal)) + if (!replacementText.EndsWith(_directorySeparator , StringComparison.Ordinal)) { - if (replacementText.EndsWith(String.Format("{0}\'", DirectorySeparatorString), StringComparison.Ordinal) || - replacementText.EndsWith(String.Format("{0}\"", DirectorySeparatorString), StringComparison.Ordinal)) + if (replacementText.EndsWith(string.Format("{0}\'", _directorySeparator), StringComparison.Ordinal) || + replacementText.EndsWith(string.Format("{0}\"", _directorySeparator), StringComparison.Ordinal)) { cursorAdjustment = -1; } @@ -391,12 +405,12 @@ private static string GetReplacementTextForDirectory(string replacementText, ref replacementText.EndsWith("\"", StringComparison.Ordinal)) { var len = replacementText.Length; - replacementText = replacementText.Substring(0, len - 1) + System.IO.Path.DirectorySeparatorChar + replacementText[len - 1]; + replacementText = replacementText.Substring(0, len - 1) + _directorySeparator + replacementText[len - 1]; cursorAdjustment = -1; } else { - replacementText = replacementText + System.IO.Path.DirectorySeparatorChar; + replacementText += _directorySeparator; } } return replacementText; @@ -561,6 +575,7 @@ public void UpdateMenuSelection(int selectedItem, bool select, bool showTooltips // Determine if showing the tooltip would scroll the top of our buffer off the screen. int lineLength = 0; + bool fullLine = false; for (var i = 0; i < toolTip.Length; i++) { char c = toolTip[i]; @@ -572,8 +587,13 @@ public void UpdateMenuSelection(int selectedItem, bool select, bool showTooltips if (c == '\r' || c == '\n') { - toolTipLines += 1; - lineLength = 0; + // If we happened to have a full line right before the newline character, then we + // skip this newline character because we already increased the line count. + if (!fullLine) + { + toolTipLines += 1; + lineLength = 0; + } } else { @@ -582,8 +602,14 @@ public void UpdateMenuSelection(int selectedItem, bool select, bool showTooltips { toolTipLines += 1; lineLength = 0; + + // Indicate that we just had a full line. + fullLine = true; + continue; } } + + fullLine = false; } // The +1 is for the blank line between the menu and tooltips. @@ -960,14 +986,25 @@ private void MenuCompleteImpl(Menu menu, CommandCompletion completions) // TODO: Shift + Backspace does not fail here? if (menuStack.Count > 1) { - var newMenu = menuStack.Pop(); - - newMenu.DrawMenu(menu, menuSelect: true); previousSelection = -1; + userCompletionText = userCompletionText.Substring(0, userCompletionText.Length - 1); - menu = newMenu; + Menu newMenu = menuStack.Peek(); + int pos = FindUserCompletionTextPosition(newMenu.CurrentMenuItem, userCompletionText); + if (pos >= 0) + { + newMenu = menuStack.Pop(); + newMenu.DrawMenu(menu, menuSelect: true); - userCompletionText = userCompletionText.Substring(0, userCompletionText.Length - 1); + menu = newMenu; + } + // else { + // We should not pop the stack yet. The updated user completion text contains characters + // that are not included in the selected item of the menu at the top of stack. This may + // happen when the user pressed a 'Tab' before this 'Backspace', which updated the user + // completion text to include the unambiguous common prefix of the available completion + // candidates. In this case, we should stay in the current menu. + // } } else if (menuStack.Count == 1) { @@ -1056,7 +1093,7 @@ private void MenuCompleteImpl(Menu menu, CommandCompletion completions) processingKeys = false; prependNextKey = true; - // without this branch experience doesnt look naturally + // without this branch experience doesn't look naturally if (_dispatchTable.TryGetValue(nextKey, out var handler) && ( handler.Action == CopyOrCancelLine || diff --git a/PSReadLine/ConsoleLib.cs b/PSReadLine/ConsoleLib.cs index ff8c1e6d9..dc1d1b455 100644 --- a/PSReadLine/ConsoleLib.cs +++ b/PSReadLine/ConsoleLib.cs @@ -31,7 +31,7 @@ public int CursorTop set => Console.CursorTop = value; } - // .NET doesn't implement this API, so we fake it with a commonly supported escape sequence. + // .NET doesn't fully implement this API on all platforms, so we fake it with a commonly supported escape sequence. protected int _unixCursorSize = 25; public virtual int CursorSize { @@ -45,9 +45,15 @@ public virtual int CursorSize else { _unixCursorSize = value; - // Solid blinking block or blinking vertical bar - Write(value > 50 ? "\x1b[2 q" : "\x1b[5 q"); } + + // See the cursor ANSI codes at https://www.real-world-systems.com/docs/ANSIcode.html, searching for 'blinking block'. + // We write out the ANSI escape sequence even if we are on Windows, where the 'Console.CursorSize' API is supported by .NET. + // This is because this API works fine in console host, but not in Windows Terminal. The escape sequence will configure the + // cursor as expected in Windows Terminal, while in console host, the escape sequence works after it's written out, but then + // will be overwritten by 'CursorSize' when the user continues typing. + // We use blinking block and blinking underscore, so as to mimic the cursor size 100 and 25 in console host. + Write(value > 50 ? "\x1b[1 q" : "\x1b[3 q"); } } @@ -107,8 +113,40 @@ public Encoding OutputEncoding set { try { Console.OutputEncoding = value; } catch { } } } - public ConsoleKeyInfo ReadKey() => _readKeyMethod.Value(true); - public bool KeyAvailable => Console.KeyAvailable; + private static T _TryIgnoreIOE(Func f) + { + int triesLeft = 10; + while (true) + { + try + { + triesLeft--; + return f(); + } + catch (InvalidOperationException) + { + // Ignore it. An IOE could be thrown if the "application does not have a + // console or when console input has been redirected"... but we don't + // expect PSReadLine to be involved in such a situation. So we are + // actually probably running into this Issue (wherein another process + // attached to the same console terminated at just the right/wrong time): + // + // https://github.com/dotnet/runtime/issues/88697 + // + // In the event there is some *other* pathological situation + // happening, we have limited the number of times we will + // swallow/retry this exception/operation. + + if (triesLeft <= 0) + { + throw; + } + } + } + } + + public ConsoleKeyInfo ReadKey() => _TryIgnoreIOE(() => _readKeyMethod.Value(true)); + public bool KeyAvailable => _TryIgnoreIOE(() => Console.KeyAvailable); public void SetWindowPosition(int left, int top) => Console.SetWindowPosition(left, top); public void SetCursorPosition(int left, int top) => Console.SetCursorPosition(left, top); public virtual void Write(string value) => Console.Write(value); diff --git a/PSReadLine/DisplayBlockBase.cs b/PSReadLine/DisplayBlockBase.cs index c4da53247..e84be99a5 100644 --- a/PSReadLine/DisplayBlockBase.cs +++ b/PSReadLine/DisplayBlockBase.cs @@ -49,7 +49,7 @@ protected void MoveCursorToStartDrawingPosition(IConsole console) { // Calculate the coord to place the cursor at the end of current input. Point bufferEndPoint = Singleton.ConvertOffsetToPoint(Singleton._buffer.Length); - // Top must be initialized before any possible adjustion by 'AdjustForPossibleScroll' or 'AdjustForActualScroll', + // Top must be initialized before any possible adjustment by 'AdjustForPossibleScroll' or 'AdjustForActualScroll', // otherwise its value would be corrupted and cause rendering issue. Top = bufferEndPoint.Y + 1; diff --git a/PSReadLine/DynamicHelp.cs b/PSReadLine/DynamicHelp.cs index 94657688e..a43e8f6a5 100644 --- a/PSReadLine/DynamicHelp.cs +++ b/PSReadLine/DynamicHelp.cs @@ -19,6 +19,7 @@ public partial class PSConsoleReadLine [ExcludeFromCodeCoverage] void IPSConsoleReadLineMockableMethods.RenderFullHelp(string content, string regexPatternToScrollTo) { + _pager ??= new Pager(); _pager.Write(content, regexPatternToScrollTo); } @@ -66,7 +67,6 @@ object IPSConsoleReadLineMockableMethods.GetDynamicHelpContent(string commandNam .AddParameter("Parameter", parameterName) .Invoke() .FirstOrDefault(); - } catch (Exception) { @@ -142,11 +142,6 @@ private void WriteDynamicHelpContent(string commandName, string parameterName, b private void DynamicHelpImpl(bool isFullHelp) { - if (isFullHelp) - { - _pager ??= new Pager(); - } - int cursor = _singleton._current; string commandName = null; string parameterName = null; @@ -196,34 +191,53 @@ private void WriteDynamicHelpBlock(Collection helpBlock) private void WriteParameterHelp(dynamic helpContent) { + System.Diagnostics.Debug.Assert(helpContent is not null); + Collection helpBlock; - if (string.IsNullOrEmpty(helpContent?.Description?[0]?.Text)) + if (helpContent.Description is not string descriptionText) + { + descriptionText = helpContent.Description?[0]?.Text; + } + + if (descriptionText is null) { helpBlock = new Collection() { - String.Empty, + string.Empty, PSReadLineResources.NeedsUpdateHelp }; } else { - string syntax = $"-{helpContent.name} <{helpContent.type.name}>"; - string desc = "DESC: " + helpContent.Description[0].Text; - - // trim new line characters as some help content has it at the end of the first list on the description. - desc = desc.Trim('\r', '\n'); - - string details = $"Required: {helpContent.required}, Position: {helpContent.position}, Default Value: {helpContent.defaultValue}, Pipeline Input: {helpContent.pipelineInput}, WildCard: {helpContent.globbing}"; - + string syntax = $"-{helpContent.name} <{helpContent.type?.name}>"; helpBlock = new Collection { string.Empty, syntax, - string.Empty, - desc, - details + string.Empty }; + + string text = descriptionText; + if (text.Contains("\n")) + { + string[] lines = text.Split(new char[] { '\n' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < lines.Length; i++) + { + string prefix = i == 0 ? "DESC: " : " "; + string s = prefix + lines[i].Trim('\r'); + helpBlock.Add(s); + } + } + else + { + string desc = "DESC: " + text; + // trim new line characters as some help content has it at the end of the first list on the description. + helpBlock.Add(desc.Trim('\r', '\n')); + } + + string details = $"Required: {helpContent.required}, Position: {helpContent.position}, Default Value: {helpContent.defaultValue}, Pipeline Input: {helpContent.pipelineInput}, WildCard: {helpContent.globbing}"; + helpBlock.Add(details); } WriteDynamicHelpBlock(helpBlock); diff --git a/PSReadLine/History.cs b/PSReadLine/History.cs index 637db8dd0..c1490a230 100644 --- a/PSReadLine/History.cs +++ b/PSReadLine/History.cs @@ -128,7 +128,8 @@ public class HistoryItem "Set-SecretVaultDefault", "Test-SecretVault", "Unlock-SecretVault", - "Unregister-SecretVault" + "Unregister-SecretVault", + "Get-AzAccessToken", }; private void ClearSavedCurrentLine() @@ -139,7 +140,7 @@ private void ClearSavedCurrentLine() _savedCurrentLine._editGroupStart = -1; } - private AddToHistoryOption GetAddToHistoryOption(string line) + private AddToHistoryOption GetAddToHistoryOption(string line, bool fromHistoryFile) { // Whitespace only is useless, never add. if (string.IsNullOrWhiteSpace(line)) @@ -154,7 +155,7 @@ private AddToHistoryOption GetAddToHistoryOption(string line) return AddToHistoryOption.SkipAdding; } - if (Options.AddToHistoryHandler != null) + if (!fromHistoryFile && Options.AddToHistoryHandler != null) { if (Options.AddToHistoryHandler == PSConsoleReadLineOptions.DefaultAddToHistoryHandler) { @@ -203,10 +204,11 @@ private string MaybeAddToHistory( bool fromDifferentSession = false, bool fromInitialRead = false) { - var addToHistoryOption = GetAddToHistoryOption(result); + bool fromHistoryFile = fromDifferentSession || fromInitialRead; + var addToHistoryOption = GetAddToHistoryOption(result, fromHistoryFile); + if (addToHistoryOption != AddToHistoryOption.SkipAdding) { - var fromHistoryFile = fromDifferentSession || fromInitialRead; _previousHistoryItem = new HistoryItem { CommandLine = result, @@ -455,12 +457,61 @@ private void ReadHistoryFile() { WithHistoryFileMutexDo(1000, () => { - var historyLines = File.ReadAllLines(Options.HistorySavePath); + var historyLines = ReadHistoryLinesImpl(Options.HistorySavePath, Options.MaximumHistoryCount); UpdateHistoryFromFile(historyLines, fromDifferentSession: false, fromInitialRead: true); var fileInfo = new FileInfo(Options.HistorySavePath); _historyFileLastSavedSize = fileInfo.Length; }); } + + static IEnumerable ReadHistoryLinesImpl(string path, int historyCount) + { + const long offset_1mb = 1048576; + const long offset_05mb = 524288; + + // 1mb content contains more than 34,000 history lines for a typical usage, which should be + // more than enough to cover 20,000 history records (a history record could be a multi-line + // command). Similarly, 0.5mb content should be enough to cover 10,000 history records. + // We optimize the file reading when the history count falls in those ranges. If the history + // count is even larger, which should be very rare, we just read all lines. + long offset = historyCount switch + { + <= 10000 => offset_05mb, + <= 20000 => offset_1mb, + _ => 0, + }; + + using var fs = new FileStream(path, FileMode.Open); + using var sr = new StreamReader(fs); + + if (offset > 0 && fs.Length > offset) + { + // When the file size is larger than the offset, we only read that amount of content from the end. + fs.Seek(-offset, SeekOrigin.End); + + // After seeking, the current position may point at the middle of a history record, or even at a + // byte within a UTF-8 character (history file is saved with UTF-8 encoding). So, let's ignore the + // first line read from that position. + sr.ReadLine(); + + string line; + while ((line = sr.ReadLine()) is not null) + { + if (!line.EndsWith("`", StringComparison.Ordinal)) + { + // A complete history record is guaranteed to start from the next line. + break; + } + } + } + + // Read lines in the streaming way, so it won't consume to much memory even if we have to + // read all lines from a large history file. + while (!sr.EndOfStream) + { + yield return sr.ReadLine(); + } + } } void UpdateHistoryFromFile(IEnumerable historyLines, bool fromDifferentSession, bool fromInitialRead) @@ -511,15 +562,48 @@ private static bool IsOnLeftSideOfAnAssignment(Ast ast, out Ast rhs) return result; } + private static bool IsRightSideOfAnAssignmentSafe(Ast rhs) + { + if (rhs is PipelineAst) + { + // Right hand side is a pipeline. + return true; + } + + if (rhs is CommandExpressionAst cmdExprAst && cmdExprAst.Expression is MemberExpressionAst or InvokeMemberExpressionAst) + { + // Right hand side is a member access, or method invocation. + return true; + } + + return false; + } + private static bool IsSecretMgmtCommand(StringConstantExpressionAst strConst, out CommandAst command) { + command = null; bool result = false; - command = strConst.Parent as CommandAst; - if (command is not null) + if (strConst.Parent is CommandAst cmdAst && ReferenceEquals(cmdAst.CommandElements[0], strConst) && s_SecretMgmtCommands.Contains(strConst.Value)) { - result = ReferenceEquals(command.CommandElements[0], strConst) - && s_SecretMgmtCommands.Contains(strConst.Value); + result = true; + command = cmdAst; + } + + return result; + } + + private static bool IsSafePropertyUsage(Ast member) + { + bool result = false; + + if (member.Parent is MemberExpressionAst memberExpr) + { + // - If the property is NOT on the left side of an assignment, then it's safe. + // - Otherwise, if the right-hand side is a pipeline or a variable, then we consider it safe. + result = !IsOnLeftSideOfAnAssignment(memberExpr, out Ast rhs) + || rhs is PipelineAst + || (rhs is CommandExpressionAst cmdExpr && cmdExpr.Expression is VariableExpressionAst); } return result; @@ -552,6 +636,45 @@ private static ExpressionAst GetArgumentForParameter(CommandParameterAst param) return null; } + private static bool IsCloudTokenOrSecretAccess(StringConstantExpressionAst arg2Ast, out CommandAst command) + { + bool result = false; + command = arg2Ast.Parent as CommandAst; + + if (command is not null && command.CommandElements.Count >= 3 + && command.CommandElements[0] is StringConstantExpressionAst nameAst + && command.CommandElements[1] is StringConstantExpressionAst arg1Ast + && command.CommandElements[2] == arg2Ast) + { + string name = nameAst.Value; + string arg1 = arg1Ast.Value; + string arg2 = arg2Ast.Value; + + if (string.Equals(name, "gcloud", StringComparison.OrdinalIgnoreCase)) + { + result = string.Equals(arg1, "auth", StringComparison.OrdinalIgnoreCase) && + string.Equals(arg2, "print-access-token", StringComparison.OrdinalIgnoreCase); + } + else if (string.Equals(name, "az", StringComparison.OrdinalIgnoreCase)) + { + result = string.Equals(arg1, "account", StringComparison.OrdinalIgnoreCase) && + string.Equals(arg2, "get-access-token", StringComparison.OrdinalIgnoreCase); + } + else if (string.Equals(name, "kubectl", StringComparison.OrdinalIgnoreCase)) + { + result = (string.Equals(arg1, "get", StringComparison.OrdinalIgnoreCase) || string.Equals(arg1, "describe", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(arg2, "secrets", StringComparison.OrdinalIgnoreCase) || string.Equals(arg2, "secret", StringComparison.OrdinalIgnoreCase)); + } + } + + if (!result) + { + command = null; + } + + return result; + } + public static AddToHistoryOption GetDefaultAddToHistoryOption(string line) { if (string.IsNullOrEmpty(line)) @@ -602,8 +725,7 @@ public static AddToHistoryOption GetDefaultAddToHistoryOption(string line) // If it appears on the left-hand-side of an assignment, and the right-hand-side is // not a command invocation, we consider it sensitive. // e.g. `$token = Get-Secret` vs. `$token = 'token-text'` or `$token, $url = ...` - isSensitive = IsOnLeftSideOfAnAssignment(innerAst, out Ast rhs) - && rhs is not PipelineAst; + isSensitive = IsOnLeftSideOfAnAssignment(innerAst, out Ast rhs) && !IsRightSideOfAnAssignmentSafe(rhs); if (!isSensitive) { @@ -612,15 +734,21 @@ public static AddToHistoryOption GetDefaultAddToHistoryOption(string line) break; case StringConstantExpressionAst strConst: - // If it's not a command name, or it's not one of the secret management commands that - // we can ignore, we consider it sensitive. - isSensitive = !IsSecretMgmtCommand(strConst, out CommandAst command); - - if (!isSensitive) + isSensitive = true; + if (IsSecretMgmtCommand(strConst, out CommandAst command) + || IsCloudTokenOrSecretAccess(strConst, out command)) { - // We can safely skip the whole command text. + // If it's one of the secret management commands that we can ignore, we consider it safe. + isSensitive = false; + // And we can safely skip the whole command text in this case. match = s_sensitivePattern.Match(line, command.Extent.EndOffset); } + else if (IsSafePropertyUsage(strConst)) + { + isSensitive = false; + match = match.NextMatch(); + } + break; case CommandParameterAst param: diff --git a/PSReadLine/KeyBindings.cs b/PSReadLine/KeyBindings.cs index ce73a1006..89564228d 100644 --- a/PSReadLine/KeyBindings.cs +++ b/PSReadLine/KeyBindings.cs @@ -230,17 +230,18 @@ void SetDefaultWindowsBindings() { Keys.AltMinus, MakeKeyHandler(DigitArgument, "DigitArgument") }, { Keys.AltQuestion, MakeKeyHandler(WhatIsKey, "WhatIsKey") }, { Keys.AltA, MakeKeyHandler(SelectCommandArgument, "SelectCommandArgument") }, + { Keys.AltH, MakeKeyHandler(ShowParameterHelp, "ShowParameterHelp") }, + { Keys.F1, MakeKeyHandler(ShowCommandHelp, "ShowCommandHelp") }, { Keys.F2, MakeKeyHandler(SwitchPredictionView, "SwitchPredictionView") }, { Keys.F3, MakeKeyHandler(CharacterSearch, "CharacterSearch") }, { Keys.ShiftF3, MakeKeyHandler(CharacterSearchBackward, "CharacterSearchBackward") }, + { Keys.F4, MakeKeyHandler(ShowFullPredictionTooltip, "ShowFullPredictionTooltip") }, { Keys.F8, MakeKeyHandler(HistorySearchBackward, "HistorySearchBackward") }, { Keys.ShiftF8, MakeKeyHandler(HistorySearchForward, "HistorySearchForward") }, // Added for xtermjs-based terminals that send different key combinations. { Keys.AltD, MakeKeyHandler(KillWord, "KillWord") }, { Keys.CtrlAt, MakeKeyHandler(MenuComplete, "MenuComplete") }, { Keys.CtrlW, MakeKeyHandler(BackwardKillWord, "BackwardKillWord") }, - { Keys.AltH, MakeKeyHandler(ShowParameterHelp, "ShowParameterHelp") }, - { Keys.F1, MakeKeyHandler(ShowCommandHelp, "ShowCommandHelp") }, }; // Some bindings are not available on certain platforms @@ -338,6 +339,11 @@ void SetDefaultEmacsBindings() { Keys.AltA, MakeKeyHandler(SelectCommandArgument, "SelectCommandArgument") }, { Keys.AltH, MakeKeyHandler(ShowParameterHelp, "ShowParameterHelp") }, { Keys.F1, MakeKeyHandler(ShowCommandHelp, "ShowCommandHelp") }, + { Keys.F2, MakeKeyHandler(SwitchPredictionView, "SwitchPredictionView") }, + { Keys.F4, MakeKeyHandler(ShowFullPredictionTooltip, "ShowFullPredictionTooltip") }, + { Keys.AltU, MakeKeyHandler(UpcaseWord, "UpcaseWord") }, + { Keys.AltL, MakeKeyHandler(DowncaseWord, "DowncaseWord") }, + { Keys.AltC, MakeKeyHandler(CapitalizeWord, "CapitalizeWord") }, }; // Some bindings are not available on certain platforms @@ -370,6 +376,9 @@ void SetDefaultEmacsBindings() { Keys.F, MakeKeyHandler(ForwardWord, "ForwardWord")}, { Keys.R, MakeKeyHandler(RevertLine, "RevertLine")}, { Keys.Y, MakeKeyHandler(YankPop, "YankPop")}, + { Keys.U, MakeKeyHandler(UpcaseWord, "UpcaseWord") }, + { Keys.L, MakeKeyHandler(DowncaseWord, "DowncaseWord") }, + { Keys.C, MakeKeyHandler(CapitalizeWord, "CapitalizeWord") }, { Keys.CtrlY, MakeKeyHandler(YankNthArg, "YankNthArg")}, { Keys.Backspace, MakeKeyHandler(BackwardKillWord, "BackwardKillWord")}, { Keys.Period, MakeKeyHandler(YankLastArg, "YankLastArg")}, @@ -398,14 +407,15 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(AcceptAndGetNext): case nameof(AcceptLine): case nameof(AddLine): - case nameof(BackwardDeleteInput): case nameof(BackwardDeleteChar): + case nameof(BackwardDeleteInput): case nameof(BackwardDeleteLine): case nameof(BackwardDeleteWord): case nameof(BackwardKillInput): case nameof(BackwardKillLine): case nameof(BackwardKillWord): case nameof(CancelLine): + case nameof(CapitalizeWord): case nameof(Copy): case nameof(CopyOrCancelLine): case nameof(Cut): @@ -413,13 +423,14 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(DeleteCharOrExit): case nameof(DeleteEndOfBuffer): case nameof(DeleteEndOfWord): - case nameof(DeleteRelativeLines): case nameof(DeleteLine): case nameof(DeleteLineToFirstChar): case nameof(DeleteNextLines): case nameof(DeletePreviousLines): + case nameof(DeleteRelativeLines): case nameof(DeleteToEnd): case nameof(DeleteWord): + case nameof(DowncaseWord): case nameof(ForwardDeleteInput): case nameof(ForwardDeleteLine): case nameof(InsertLineAbove): @@ -441,6 +452,7 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(Undo): case nameof(UndoAll): case nameof(UnixWordRubout): + case nameof(UpcaseWord): case nameof(ValidateAndAcceptLine): case nameof(ViAcceptLine): case nameof(ViAcceptLineOrExit): @@ -556,6 +568,7 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(NextSuggestion): case nameof(PreviousSuggestion): case nameof(SwitchPredictionView): + case nameof(ShowFullPredictionTooltip): return KeyHandlerGroup.Prediction; case nameof(CaptureScreen): @@ -575,6 +588,7 @@ public static KeyHandlerGroup GetDisplayGrouping(string function) case nameof(ViEditVisually): case nameof(ViExit): case nameof(ViInsertMode): + case nameof(ViDGChord): case nameof(WhatIsKey): case nameof(ShowCommandHelp): case nameof(ShowParameterHelp): @@ -653,7 +667,7 @@ public static void ShowKeyBindings(ConsoleKeyInfo? key = null, object arg = null } // Don't overwrite any of the line - so move to first line after the end of our buffer. - var point = _singleton.ConvertOffsetToPoint(_singleton._buffer.Length); + var point = _singleton.EndOfBufferPosition(); console.SetCursorPosition(point.X, point.Y); console.Write("\n"); @@ -710,7 +724,7 @@ public static void WhatIsKey(ConsoleKeyInfo? key = null, object arg = null) var console = _singleton._console; // Don't overwrite any of the line - so move to first line after the end of our buffer. - var point = _singleton.ConvertOffsetToPoint(_singleton._buffer.Length); + var point = _singleton.EndOfBufferPosition(); console.SetCursorPosition(point.X, point.Y); console.Write("\n"); diff --git a/PSReadLine/KeyBindings.vi.cs b/PSReadLine/KeyBindings.vi.cs index e7498c166..5a47c6608 100644 --- a/PSReadLine/KeyBindings.vi.cs +++ b/PSReadLine/KeyBindings.vi.cs @@ -45,6 +45,8 @@ internal static ConsoleColor AlternateBackground(ConsoleColor bg) private static Dictionary _viChordYTable; private static Dictionary _viChordDGTable; + private static Dictionary _viChordTextObjectsTable; + private static Dictionary> _viCmdChordTable; private static Dictionary> _viInsChordTable; @@ -91,6 +93,8 @@ private void SetDefaultViBindings() { Keys.CtrlG, MakeKeyHandler(Abort, "Abort") }, { Keys.AltH, MakeKeyHandler(ShowParameterHelp, "ShowParameterHelp") }, { Keys.F1, MakeKeyHandler(ShowCommandHelp, "ShowCommandHelp") }, + { Keys.F2, MakeKeyHandler(SwitchPredictionView, "SwitchPredictionView") }, + { Keys.F4, MakeKeyHandler(ShowFullPredictionTooltip, "ShowFullPredictionTooltip") }, }; // Some bindings are not available on certain platforms @@ -236,6 +240,7 @@ private void SetDefaultViBindings() { Keys.ucG, MakeKeyHandler( DeleteEndOfBuffer, "DeleteEndOfBuffer") }, { Keys.ucE, MakeKeyHandler( ViDeleteEndOfGlob, "ViDeleteEndOfGlob") }, { Keys.H, MakeKeyHandler( BackwardDeleteChar, "BackwardDeleteChar") }, + { Keys.I, MakeKeyHandler( ViChordDeleteTextObject, "ChordViTextObject") }, { Keys.J, MakeKeyHandler( DeleteNextLines, "DeleteNextLines") }, { Keys.K, MakeKeyHandler( DeletePreviousLines, "DeletePreviousLines") }, { Keys.L, MakeKeyHandler( DeleteChar, "DeleteChar") }, @@ -294,6 +299,11 @@ private void SetDefaultViBindings() { Keys.Percent, MakeKeyHandler( ViYankPercent, "ViYankPercent") }, }; + _viChordTextObjectsTable = new Dictionary + { + { Keys.W, MakeKeyHandler(ViHandleTextObject, "WordTextObject")}, + }; + _viChordDGTable = new Dictionary { { Keys.G, MakeKeyHandler( DeleteRelativeLines, "DeleteRelativeLines") }, diff --git a/PSReadLine/Keys.cs b/PSReadLine/Keys.cs index c9ed230e1..70cd80e47 100644 --- a/PSReadLine/Keys.cs +++ b/PSReadLine/Keys.cs @@ -115,13 +115,14 @@ public override int GetHashCode() public static extern uint MapVirtualKey(ConsoleKey uCode, uint uMapType); [DllImport("user32.dll", CharSet = CharSet.Unicode)] - public static extern int ToUnicode( + public static extern int ToUnicodeEx( ConsoleKey uVirtKey, uint uScanCode, byte[] lpKeyState, [MarshalAs(UnmanagedType.LPArray)] [Out] char[] chars, int charMaxCount, - uint flags); + uint flags, + IntPtr dwhkl); static readonly ThreadLocal toUnicodeBuffer = new ThreadLocal(() => new char[2]); static readonly ThreadLocal toUnicodeStateBuffer = new ThreadLocal(() => new byte[256]); @@ -139,7 +140,7 @@ internal static void TryGetCharFromConsoleKey(ConsoleKeyInfo key, ref char resul // get corresponding scan code uint scanCode = MapVirtualKey(virtualKey, 0x0 /*MAPVK_VK_TO_VSC*/); - // get corresponding character - maybe be 0, 1 or 2 in length (diacriticals) + // get corresponding character - may be 0, 1 or 2 in length (diacriticals) var chars = toUnicodeBuffer.Value; var flags = 0u; /* If bit 0 is set, a menu is active. */ var osVersion = Environment.OSVersion.Version; @@ -147,7 +148,9 @@ internal static void TryGetCharFromConsoleKey(ConsoleKeyInfo key, ref char resul { flags |= (1 << 2); /* If bit 2 is set, keyboard state is not changed (Windows 10, version 1607 and newer) */ } - int charCount = ToUnicode(virtualKey, scanCode, state, chars, chars.Length, flags); + + IntPtr layout = PlatformWindows.GetConsoleKeyboardLayout(); + int charCount = ToUnicodeEx(virtualKey, scanCode, state, chars, chars.Length, flags, layout); if (charCount == 1) { @@ -238,7 +241,7 @@ void AppendPart(string str) { // A heuristic to check for dead keys -- // We got an 'OemXXX' ConsoleKey, '\0' key char, and no 'Ctrl' modifier. It's very likely generated by a dead key. - // We check for 'Ctrl' modifier because it's easy to generate '\0' KeyChar and 'OemXXX' by combinding 'Ctrl' with + // We check for 'Ctrl' modifier because it's easy to generate '\0' KeyChar and 'OemXXX' by combining 'Ctrl' with // another special key, such as 'Ctrl+?' and 'Ctrl+;'. isDeadKey = (c == '\0') && (consoleKey >= ConsoleKey.Oem1 && consoleKey <= ConsoleKey.Oem102) && !isCtrl; diff --git a/PSReadLine/KillYank.cs b/PSReadLine/KillYank.cs index f05fc8ba0..c282b1489 100644 --- a/PSReadLine/KillYank.cs +++ b/PSReadLine/KillYank.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using System.Management.Automation.Language; +using System.Text.RegularExpressions; using Microsoft.PowerShell.Internal; namespace Microsoft.PowerShell @@ -29,6 +30,10 @@ class YankLastArgState private YankLastArgState _yankLastArgState; private int _visualSelectionCommandCount; + // Pattern to check for CLI parameters like '--json'. + // Valid characters are 'a-z', 'A-Z', '0-9', '_' (all covered by '\w'), and '-'. + private static readonly Regex s_cliOptionPattern = new(@"^--[\w-]+$", RegexOptions.Compiled); + /// /// Mark the current location of the cursor for use in a subsequent editing command. /// @@ -130,7 +135,7 @@ public static void BackwardKillInput(ConsoleKeyInfo? key = null, object arg = nu public static void BackwardKillLine(ConsoleKeyInfo? key = null, object arg = null) { var start = GetBeginningOfLinePos(_singleton._current); - _singleton.Kill(start, _singleton._current, true); + _singleton.Kill(start, _singleton._current - start, true); } /// @@ -480,7 +485,7 @@ public static void SelectCommandArgument(ConsoleKeyInfo? key = null, object arg var argument = cmdAst.CommandElements[j] switch { CommandParameterAst paramAst => paramAst.Argument, - ExpressionAst expAst => expAst, + ExpressionAst exprAst => ProcessExpressionAst(exprAst), _ => null, }; @@ -614,6 +619,7 @@ public static void SelectCommandArgument(ConsoleKeyInfo? key = null, object arg _singleton.VisualSelectionCommon(() => SetCursorPosition(newEndCursor), forceSetMark: true); + // ===== Local Functions ===== // Get the script block AST's whose extent contains the cursor. bool GetScriptBlockAst(Ast ast) { @@ -639,6 +645,22 @@ bool GetScriptBlockAst(Ast ast) ? ast.Extent.EndOffset - 1 > cursor : ast.Extent.EndOffset >= cursor; } + + // Process an expression AST to check if it's a CLI posix style option. + static ExpressionAst ProcessExpressionAst(ExpressionAst exprAst) + { + if (exprAst is StringConstantExpressionAst strAst + && strAst.StringConstantType is StringConstantType.BareWord + && strAst.Value.StartsWith("--") + && s_cliOptionPattern.IsMatch(strAst.Value)) + { + // It's a CLI posix style option, like '--json' or '--machine-type', + // so we treat it as a parameter. + return null; + } + + return exprAst; + } } /// diff --git a/PSReadLine/Movement.cs b/PSReadLine/Movement.cs index 074ae4b10..da52230e6 100644 --- a/PSReadLine/Movement.cs +++ b/PSReadLine/Movement.cs @@ -527,16 +527,16 @@ private static char TryGetArgAsChar(object arg) } /// - /// Read a character and search forward for the next occurence of that character. + /// Read a character and search forward for the next occurrence of that character. /// If an argument is specified, search forward (or backward if negative) for the - /// nth occurence. + /// nth occurrence. /// public static void CharacterSearch(ConsoleKeyInfo? key = null, object arg = null) { - int occurence = arg as int? ?? 1; - if (occurence < 0) + int occurrence = arg as int? ?? 1; + if (occurrence < 0) { - CharacterSearchBackward(key, -occurence); + CharacterSearchBackward(key, -occurrence); return; } @@ -550,31 +550,31 @@ public static void CharacterSearch(ConsoleKeyInfo? key = null, object arg = null { if (_singleton._buffer[i] == toFind) { - occurence -= 1; - if (occurence == 0) + occurrence -= 1; + if (occurrence == 0) { _singleton.MoveCursor(i); break; } } } - if (occurence > 0) + if (occurrence > 0) { Ding(); } } /// - /// Read a character and search backward for the next occurence of that character. + /// Read a character and search backward for the next occurrence of that character. /// If an argument is specified, search backward (or forward if negative) for the - /// nth occurence. + /// nth occurrence. /// public static void CharacterSearchBackward(ConsoleKeyInfo? key = null, object arg = null) { - int occurence = arg as int? ?? 1; - if (occurence < 0) + int occurrence = arg as int? ?? 1; + if (occurrence < 0) { - CharacterSearch(key, -occurence); + CharacterSearch(key, -occurrence); return; } @@ -588,8 +588,8 @@ public static void CharacterSearchBackward(ConsoleKeyInfo? key = null, object ar { if (_singleton._buffer[i] == toFind) { - occurence -= 1; - if (occurence == 0) + occurrence -= 1; + if (occurrence == 0) { _singleton.MoveCursor(i); return; diff --git a/PSReadLine/Movement.vi.cs b/PSReadLine/Movement.vi.cs index 011c146d9..542a37b7d 100644 --- a/PSReadLine/Movement.vi.cs +++ b/PSReadLine/Movement.vi.cs @@ -254,7 +254,32 @@ private int ViFindBrace(int i) case ')': return ViFindBackward(i, '(', withoutPassing: ')'); default: - return i; + ReadOnlySpan parentheses = stackalloc char[] { '{', '}', '(', ')', '[', ']' }; + int nextParen = i; + // find next of any kind of paren + for (; nextParen < _buffer.Length; nextParen++) + for (int idx = 0; idx < parentheses.Length; idx++) + if (parentheses[idx] == _buffer[nextParen]) goto Outer; + + // if not found, nextParen could exceed the range + if (nextParen >= _buffer.Length) + return i; + + Outer: + int match = _buffer[nextParen] switch + { + // if next is opening, find forward + '{' => ViFindForward(nextParen, '}', withoutPassing: '{'), + '[' => ViFindForward(nextParen, ']', withoutPassing: '['), + '(' => ViFindForward(nextParen, ')', withoutPassing: '('), + // if next is closing, find backward + '}' => ViFindBackward(nextParen, '{', withoutPassing: '}'), + ']' => ViFindBackward(nextParen, '[', withoutPassing: ']'), + ')' => ViFindBackward(nextParen, '(', withoutPassing: ')'), + _ => nextParen + }; + + return match == nextParen ? i : match; } } diff --git a/PSReadLine/OnImportAndRemove.cs b/PSReadLine/OnImportAndRemove.cs deleted file mode 100644 index 10f7000fb..000000000 --- a/PSReadLine/OnImportAndRemove.cs +++ /dev/null @@ -1,37 +0,0 @@ -ο»Ώusing System; -using System.IO; -using System.Management.Automation; -using System.Reflection; - -namespace Microsoft.PowerShell.PSReadLine -{ - public class OnModuleImportAndRemove : IModuleAssemblyInitializer, IModuleAssemblyCleanup - { - public void OnImport() - { - AppDomain.CurrentDomain.AssemblyResolve += ResolveAssembly; - } - - public void OnRemove(PSModuleInfo module) - { - AppDomain.CurrentDomain.AssemblyResolve -= ResolveAssembly; - } - - /// - /// Load the correct 'Polyfiller' assembly based on the runtime. - /// - private static Assembly ResolveAssembly(object sender, ResolveEventArgs args) - { - if (args.Name != "Microsoft.PowerShell.PSReadLine.Polyfiller, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null") - { - return null; - } - - string root = Path.GetDirectoryName(typeof(OnModuleImportAndRemove).Assembly.Location); - string subd = (Environment.Version.Major >= 6) ? "net6plus" : "net462"; - string path = Path.Combine(root, subd, "Microsoft.PowerShell.PSReadLine.Polyfiller.dll"); - - return Assembly.LoadFrom(path); - } - } -} diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs index 19f366513..ffdf0241c 100644 --- a/PSReadLine/Options.cs +++ b/PSReadLine/Options.cs @@ -6,8 +6,10 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Management.Automation; using System.Reflection; +using System.Runtime.InteropServices; using System.Threading; using Microsoft.PowerShell.PSReadLine; @@ -167,6 +169,26 @@ private void SetOptionsInternal(SetPSReadLineOption options) } } } + if (options._terminateOrphanedConsoleApps.HasValue) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Options.TerminateOrphanedConsoleApps = options.TerminateOrphanedConsoleApps; + PlatformWindows.SetTerminateOrphanedConsoleApps(Options.TerminateOrphanedConsoleApps); + } + else + { + throw new PlatformNotSupportedException( + string.Format( + CultureInfo.CurrentUICulture, + PSReadLineResources.OptionNotSupportedOnNonWindows, + nameof(Options.TerminateOrphanedConsoleApps))); + } + } + if (options._enableScreenReaderMode.HasValue) + { + Options.ScreenReaderModeEnabled = options.EnableScreenReaderMode; + } } private void SetKeyHandlerInternal(string[] keys, Action handler, string briefDescription, string longDescription, ScriptBlock scriptBlock) diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj index 4126da0b7..edb39c91b 100644 --- a/PSReadLine/PSReadLine.csproj +++ b/PSReadLine/PSReadLine.csproj @@ -3,30 +3,24 @@ Library Microsoft.PowerShell.PSReadLine - Microsoft.PowerShell.PSReadLine2 + Microsoft.PowerShell.PSReadLine $(NoWarn);CA1416 - 2.2.3.0 - 2.2.3 - 2.2.3 + 3.0.0.0 + 3.0.0 + 3.0.0 true - net462;net6.0 + net8.0 true + false 9.0 - - - - - - - - - - - - + + contentFiles + All + + diff --git a/PSReadLine/PSReadLine.format.ps1xml b/PSReadLine/PSReadLine.format.ps1xml index 3446753f2..62867e762 100644 --- a/PSReadLine/PSReadLine.format.ps1xml +++ b/PSReadLine/PSReadLine.format.ps1xml @@ -144,6 +144,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) ShowToolTips + + ScreenReaderModeEnabled + ViModeIndicator @@ -164,6 +167,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) PredictionViewStyle + + TerminateOrphanedConsoleApps + [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.CommandColor) @@ -204,6 +210,10 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group) [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.ListPredictionSelectedColor) + + + [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.ListPredictionTooltipColor) + [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.MemberColor) diff --git a/PSReadLine/PSReadLine.psd1 b/PSReadLine/PSReadLine.psd1 index 88359a900..4ce7eff73 100644 --- a/PSReadLine/PSReadLine.psd1 +++ b/PSReadLine/PSReadLine.psd1 @@ -1,19 +1,17 @@ ο»Ώ@{ RootModule = 'PSReadLine.psm1' -NestedModules = @("Microsoft.PowerShell.PSReadLine2.dll") -ModuleVersion = '2.2.3' +NestedModules = @("Microsoft.PowerShell.PSReadLine.dll") +ModuleVersion = '3.0.0' GUID = '5714753b-2afd-4492-a5fd-01d9e2cff8b5' Author = 'Microsoft Corporation' CompanyName = 'Microsoft Corporation' Copyright = '(c) Microsoft Corporation. All rights reserved.' Description = 'Great command line editing in the PowerShell console host' -PowerShellVersion = '5.0' -DotNetFrameworkVersion = '4.6.2' -CLRVersion = '4.0.0' +PowerShellVersion = '7.4' FormatsToProcess = 'PSReadLine.format.ps1xml' AliasesToExport = @() FunctionsToExport = 'PSConsoleHostReadLine' CmdletsToExport = 'Get-PSReadLineKeyHandler','Set-PSReadLineKeyHandler','Remove-PSReadLineKeyHandler', 'Get-PSReadLineOption','Set-PSReadLineOption' -HelpInfoURI = 'https://aka.ms/powershell72-help' +HelpInfoURI = 'https://aka.ms/powershell75-help' } diff --git a/PSReadLine/PSReadLine.psm1 b/PSReadLine/PSReadLine.psm1 index 74c271419..572aee80f 100644 --- a/PSReadLine/PSReadLine.psm1 +++ b/PSReadLine/PSReadLine.psm1 @@ -1,5 +1,8 @@ function PSConsoleHostReadLine { + [System.Diagnostics.DebuggerHidden()] + param() + ## Get the execution status of the last accepted user input. ## This needs to be done as the first thing because any script run will flush $?. $lastRunStatus = $? diff --git a/PSReadLine/PSReadLine.sln b/PSReadLine/PSReadLine.sln index 3ce7ce89f..866d96120 100644 --- a/PSReadLine/PSReadLine.sln +++ b/PSReadLine/PSReadLine.sln @@ -1,53 +1,61 @@ -ο»Ώ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.12 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSReadLine", "PSReadLine.csproj", "{615788CB-1B9A-4B34-97B3-4608686E59CA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyfill", "..\Polyfill\Polyfill.csproj", "{DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MockPSConsole", "..\MockPSConsole\MockPSConsole.csproj", "{08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSReadLine.Tests", "..\test\PSReadLine.Tests.csproj", "{8ED51D01-158C-4B29-824A-35B9B861E45A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - CodeCoverage|Any CPU = CodeCoverage|Any CPU - Debug|Any CPU = Debug|Any CPU - Linux|Any CPU = Linux|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {615788CB-1B9A-4B34-97B3-4608686E59CA}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.Linux|Any CPU.ActiveCfg = Release|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.Linux|Any CPU.Build.0 = Release|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {615788CB-1B9A-4B34-97B3-4608686E59CA}.Release|Any CPU.Build.0 = Release|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Linux|Any CPU.ActiveCfg = Release|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Linux|Any CPU.Build.0 = Release|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Release|Any CPU.Build.0 = Release|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.Linux|Any CPU.ActiveCfg = Release|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.Linux|Any CPU.Build.0 = Release|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8ED51D01-158C-4B29-824A-35B9B861E45A}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {EA13C704-483F-4CE4-A3FB-8F79295F1071} - EndGlobalSection -EndGlobal +ο»Ώ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.12 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSReadLine", "PSReadLine.csproj", "{615788CB-1B9A-4B34-97B3-4608686E59CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Polyfill", "..\Polyfill\Polyfill.csproj", "{DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MockPSConsole", "..\MockPSConsole\MockPSConsole.csproj", "{08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSReadLine.Tests", "..\test\PSReadLine.Tests.csproj", "{8ED51D01-158C-4B29-824A-35B9B861E45A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + CodeCoverage|Any CPU = CodeCoverage|Any CPU + Debug|Any CPU = Debug|Any CPU + Linux|Any CPU = Linux|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {615788CB-1B9A-4B34-97B3-4608686E59CA}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.Linux|Any CPU.ActiveCfg = Release|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.Linux|Any CPU.Build.0 = Release|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {615788CB-1B9A-4B34-97B3-4608686E59CA}.Release|Any CPU.Build.0 = Release|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.Linux|Any CPU.ActiveCfg = Release|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.Linux|Any CPU.Build.0 = Release|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE521A7D-A3BE-4A07-BE75-5AB7D87E799D}.Release|Any CPU.Build.0 = Release|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Linux|Any CPU.ActiveCfg = Release|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Linux|Any CPU.Build.0 = Release|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08218B1A-8B85-4722-9E3F-4D6C0BF58AD8}.Release|Any CPU.Build.0 = Release|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.CodeCoverage|Any CPU.ActiveCfg = Release|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.CodeCoverage|Any CPU.Build.0 = Release|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.Linux|Any CPU.ActiveCfg = Release|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.Linux|Any CPU.Build.0 = Release|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ED51D01-158C-4B29-824A-35B9B861E45A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {EA13C704-483F-4CE4-A3FB-8F79295F1071} + EndGlobalSection +EndGlobal diff --git a/PSReadLine/PSReadLineResources.Designer.cs b/PSReadLine/PSReadLineResources.Designer.cs index d107632a6..ac672d3f7 100644 --- a/PSReadLine/PSReadLineResources.Designer.cs +++ b/PSReadLine/PSReadLineResources.Designer.cs @@ -241,7 +241,7 @@ internal static string CaptureScreenDescription { } /// - /// Looks up a localized string similar to Read a character and move the cursor to the previous occurence of that character. + /// Looks up a localized string similar to Read a character and move the cursor to the previous occurrence of that character. /// internal static string CharacterSearchBackwardDescription { get { @@ -250,7 +250,7 @@ internal static string CharacterSearchBackwardDescription { } /// - /// Looks up a localized string similar to Read a character and move the cursor to the next occurence of that character. + /// Looks up a localized string similar to Read a character and move the cursor to the next occurrence of that character. /// internal static string CharacterSearchDescription { get { @@ -2070,6 +2070,17 @@ internal static string WindowSizeTooSmallForListView } } + /// + /// Looks up a localized string similar to "! terminal size too small to show the list view". + /// + internal static string WindowSizeTooSmallWarning + { + get + { + return ResourceManager.GetString("WindowSizeTooSmallWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Copy the text from the current kill ring position to the input. /// @@ -2181,6 +2192,17 @@ internal static string SwitchPredictionViewDescription } } + /// + /// Looks up a localized string similar to Show the full tooltip of the selected list-view item in the terminal's alternate screen buffer. + /// + internal static string ShowFullPredictionTooltipDescription + { + get + { + return ResourceManager.GetString("ShowFullPredictionTooltipDescription", resourceCulture); + } + } + /// /// Looks up a localized string similar to Make visual selection of the command arguments. /// @@ -2202,5 +2224,49 @@ internal static string FailedToConvertPointToRenderDataOffset return ResourceManager.GetString("FailedToConvertPointToRenderDataOffset", resourceCulture); } } + + /// + /// Looks up a localized string similar to: Find the next word starting from the current position and then upcase the first character and downcase the remaining characters. + /// + internal static string CapitalizeWordDescription + { + get + { + return ResourceManager.GetString("CapitalizeWordDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to: Find the next word starting from the current position and then make it lower case. + /// + internal static string DowncaseWordDescription + { + get + { + return ResourceManager.GetString("DowncaseWordDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to: Find the next word starting from the current position and then make it upper case. + /// + internal static string UpcaseWordDescription + { + get + { + return ResourceManager.GetString("UpcaseWordDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to: The '{0}' option is not supported on non-Windows platforms. + /// + internal static string OptionNotSupportedOnNonWindows + { + get + { + return ResourceManager.GetString("OptionNotSupportedOnNonWindows", resourceCulture); + } + } } } diff --git a/PSReadLine/PSReadLineResources.resx b/PSReadLine/PSReadLineResources.resx index f57835029..e619e7c06 100644 --- a/PSReadLine/PSReadLineResources.resx +++ b/PSReadLine/PSReadLineResources.resx @@ -292,10 +292,10 @@ Move the text from the cursor to the start of the current or previous whitespace delimited word to the kill ring - Read a character and move the cursor to the previous occurence of that character + Read a character and move the cursor to the previous occurrence of that character - Read a character and move the cursor to the next occurence of that character + Read a character and move the cursor to the next occurrence of that character Start or accumulate a numeric argument to other functions @@ -837,9 +837,15 @@ Or not saving history with: Switch between the inline and list prediction views. + + Show the full tooltip of the selected list-view item in the terminal's alternate screen buffer. + The prediction 'ListView' is temporarily disabled because the current window size of the console is too small. To use the 'ListView', please make sure the 'WindowWidth' is not less than '{0}' and the 'WindowHeight' is not less than '{1}'. + + ! terminal size too small to show the list view + No help content available. Please use Update-Help to download the latest help content. @@ -858,4 +864,16 @@ Or not saving history with: Cannot locate the offset in the rendered text that was pointed by the original cursor. Initial Coord: ({0}, {1}) Buffer: ({2}, {3}) Cursor: ({4}, {5}) + + Find the next word starting from the current position and then upcase the first character and downcase the remaining characters. + + + Find the next word starting from the current position and then make it lower case. + + + Find the next word starting from the current position and then make it upper case. + + + The '{0}' option is not supported on non-Windows platforms. + diff --git a/PSReadLine/PlatformWindows.cs b/PSReadLine/PlatformWindows.cs index b28077ada..2bf72eb61 100644 --- a/PSReadLine/PlatformWindows.cs +++ b/PSReadLine/PlatformWindows.cs @@ -6,7 +6,9 @@ using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; +using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using Microsoft.PowerShell; using Microsoft.PowerShell.Internal; using Microsoft.Win32.SafeHandles; @@ -78,6 +80,20 @@ IntPtr templateFileWin32Handle [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] internal static extern IntPtr GetStdHandle(uint handleId); + internal static bool IsMutexPresent(string name) + { + try + { + if (Mutex.TryOpenExisting(name, out var tempMutex)) + { + tempMutex.Dispose(); + return true; + } + } + catch { } + return false; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern bool SetConsoleCtrlHandler(BreakHandler handlerRoutine, bool add); @@ -139,6 +155,7 @@ internal static IConsole OneTimeInit(PSConsoleReadLine singleton) var breakHandlerGcHandle = GCHandle.Alloc(new BreakHandler(OnBreak)); SetConsoleCtrlHandler((BreakHandler)breakHandlerGcHandle.Target, true); _enableVtOutput = !Console.IsOutputRedirected && SetConsoleOutputVirtualTerminalProcessing(); + _terminalOwnerThreadId = GetTerminalOwnerThreadId(); return _enableVtOutput ? new VirtualTerminal() : new LegacyWin32Console(); } @@ -201,6 +218,15 @@ internal static void Init(ref ICharMap charMap) EnableAnsiInput(ref charMap); } + // Is the TerminateOrphanedConsoleApps feature enabled? + if (_allowedPids != null) + { + // We are about to disable Ctrl+C signals... so if there are still any + // console-attached children, the shell will be broken until they are + // gone, so we'll get rid of them: + TerminateStragglers(); + } + SetOurInputMode(); } } @@ -289,11 +315,11 @@ internal static void CallUsingOurInputMode(Action a) } } - private static readonly Lazy _inputHandle = new Lazy(() => + private static SafeFileHandle OpenConsoleHandle(string name) { // We use CreateFile here instead of GetStdWin32Handle, as GetStdWin32Handle will return redirected handles var handle = CreateFile( - "CONIN$", + name, (uint)(AccessQualifiers.GenericRead | AccessQualifiers.GenericWrite), (uint)ShareModes.ShareWrite, (IntPtr)0, @@ -305,33 +331,14 @@ internal static void CallUsingOurInputMode(Action a) { int err = Marshal.GetLastWin32Error(); Win32Exception innerException = new Win32Exception(err); - throw new Exception("Failed to retrieve the input console handle.", innerException); + throw new Exception($"Failed to retrieve the console handle ({name}).", innerException); } return new SafeFileHandle(handle, true); - }); - - private static readonly Lazy _outputHandle = new Lazy(() => - { - // We use CreateFile here instead of GetStdWin32Handle, as GetStdWin32Handle will return redirected handles - var handle = CreateFile( - "CONOUT$", - (uint)(AccessQualifiers.GenericRead | AccessQualifiers.GenericWrite), - (uint)ShareModes.ShareWrite, - (IntPtr)0, - (uint)CreationDisposition.OpenExisting, - 0, - (IntPtr)0); - - if (handle == INVALID_HANDLE_VALUE) - { - int err = Marshal.GetLastWin32Error(); - Win32Exception innerException = new Win32Exception(err); - throw new Exception("Failed to retrieve the input console handle.", innerException); - } + } - return new SafeFileHandle(handle, true); - }); + private static readonly Lazy _inputHandle = new Lazy(() => OpenConsoleHandle("CONIN$")); + private static readonly Lazy _outputHandle = new Lazy(() => OpenConsoleHandle("CONOUT$")); [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool GetConsoleMode(IntPtr hConsole, out uint dwMode); @@ -343,6 +350,13 @@ private static uint GetConsoleInputMode() return result; } + private static uint GetConsoleOutputMode() + { + var handle = _outputHandle.Value.DangerousGetHandle(); + GetConsoleMode(handle, out var result); + return result; + } + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool SetConsoleMode(IntPtr hConsole, uint dwMode); @@ -614,4 +628,508 @@ public override void BlankRestOfLine() [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref bool pvParam, uint fWinIni); + + [StructLayout(LayoutKind.Sequential)] + internal struct PROCESS_BASIC_INFORMATION + { + public IntPtr ExitStatus; + public IntPtr PebBaseAddress; + public IntPtr AffinityMask; + public IntPtr BasePriority; + public IntPtr UniqueProcessId; + public IntPtr InheritedFromUniqueProcessId; + } + + [DllImport("ntdll.dll")] + internal static extern int NtQueryInformationProcess( + IntPtr processHandle, + int processInformationClass, + out PROCESS_BASIC_INFORMATION processInformation, + int processInformationLength, + out int returnLength); + + internal const int InvalidProcessId = -1; + + internal static int GetParentPid(Process process) + { + // (This is how ProcessCodeMethods in pwsh does it.) + var res = NtQueryInformationProcess(process.Handle, 0, out PROCESS_BASIC_INFORMATION pbi, Marshal.SizeOf(), out _); + return res != 0 ? InvalidProcessId : pbi.InheritedFromUniqueProcessId.ToInt32(); + } + + [DllImport("kernel32.dll", SetLastError = true, EntryPoint = "GetConsoleProcessList")] + private static extern uint native_GetConsoleProcessList([In, Out] uint[] lpdwProcessList, uint dwProcessCount); + + private static uint[] GetConsoleProcessList() + { + int size = 100; + uint[] pids = new uint[size]; + uint numPids = native_GetConsoleProcessList(pids, (uint) size); + + if (numPids > size) + { + size = (int) numPids + 10; // a bit extra, since we may be racing attaches. + pids = new uint[size]; + numPids = native_GetConsoleProcessList(pids, (uint) size); + } + + if (0 == numPids || numPids > size) + { + return null; // no TerminateOrphanedConsoleApps for you, sorry + } + + Array.Resize(ref pids, (int) numPids); + return pids; + } + + // If the TerminateOrphanedConsoleApps option is enabled, this is the list of PIDs + // that are allowed to stay attached to the console (effectively the current process + // plus ancestors). + private static uint[] _allowedPids; + + internal static void SetTerminateOrphanedConsoleApps(bool enabled) + { + if (enabled) + { + _allowedPids = GetConsoleProcessList(); + } + else + { + _allowedPids = null; + } + } + + private static bool ItLooksLikeWeAreInTerminal() + { + return !String.IsNullOrEmpty(Environment.GetEnvironmentVariable("WT_SESSION")); + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr GetConsoleWindow(); + + internal enum TaskbarStates + { + NoProgress = 0, + Indeterminate = 0x1, + Normal = 0x2, + Error = 0x4, + Paused = 0x8, + } + + internal static class TaskbarProgress + { + [ComImport()] + [Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + private interface ITaskbarList3 + { + // ITaskbarList + [PreserveSig] + int HrInit(); + + [PreserveSig] + int AddTab(IntPtr hwnd); + + [PreserveSig] + int DeleteTab(IntPtr hwnd); + + [PreserveSig] + int ActivateTab(IntPtr hwnd); + + [PreserveSig] + int SetActiveAlt(IntPtr hwnd); + + // ITaskbarList2 + [PreserveSig] + int MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fFullscreen); + + // ITaskbarList3 + [PreserveSig] + int SetProgressValue(IntPtr hwnd, UInt64 ullCompleted, UInt64 ullTotal); + + [PreserveSig] + int SetProgressState(IntPtr hwnd, TaskbarStates state); + + // N.B. for copy/pasters: we've left out the rest of the ITaskbarList3 methods... + } + + [ComImport()] + [Guid("56fdf344-fd6d-11d0-958a-006097c9a090")] + [ClassInterface(ClassInterfaceType.None)] + private class TaskbarInstance + { + } + + private static Lazy _taskbarInstance = new Lazy(() => (ITaskbarList3) new TaskbarInstance()); + + public static int SetProgressState(IntPtr windowHandle, TaskbarStates taskbarState) + { + return _taskbarInstance.Value.SetProgressState(windowHandle, taskbarState); + } + + public static int SetProgressValue(IntPtr windowHandle, int progressValue, int progressMax) + { + return _taskbarInstance.Value.SetProgressValue(windowHandle, (ulong) progressValue, (ulong) progressMax); + } + } + + private static readonly Lazy _myPid = new(() => + { + using var me = Process.GetCurrentProcess(); + return (uint)me.Id; + }); + + // Calculates what processes need to be terminated (populated into procsToTerminate), + // and returns the count. A "straggler" is a console-attached process (so GUI + // processes don't count) that is not in the _allowedPids list. + private static int GatherStragglers(List procsToTerminate) + { + procsToTerminate.Clear(); + + // These are the processes currently attached to this console. Note that GUI + // processes will not be attached to the console. + uint[] currentPids = GetConsoleProcessList(); + + foreach (var pid in currentPids) + { + if (!_allowedPids.Contains(pid)) + { + Process proc = null; + try + { + proc = Process.GetProcessById((int) pid); + } + catch (ArgumentException) + { + // Ignore it: process could be gone, or something else that we + // likely can't do anything about it. + } + + if (proc != null) + { + // Q: Why the check against the parent pid (below)? + // + // A: The idea is that a user could do something like this: + // + // $p = Start-Process pwsh -ArgumentList '-c Write-Host start $pid; sleep -seconds 30; Write-Host stop' -NoNewWindow -passThru + // + // Such a process *is* indeed _capable_ of wrecking the interactive prompt (all it has to do is to attempt to read input; and any output + // will be interleaved with your interactive session)... but MAYBE it won't. So the idea with letting such processes live is that perhaps + // the user did this on purpose, to do some sort of "background work" (even though it may not seem like the best way to do that); and we + // only want to kill *actually-orphaned* processes: processes whose parent is gone, so they should be gone, too. + // + // We only check the immediate children processes here for simplicity. However, an immediate child process may have children that accidentally + // derive the standard input (which technically is a wrong thing to do), so ideally we should check if the parent of a console-attached process + // is still alive -- the parent process id points to an alive process that was created earlier. + // We will wait for feedback to see if this check needs to be updated. + + if (GetParentPid(proc) != _myPid.Value) + { + procsToTerminate.Add(proc); + } + else + { + proc.Dispose(); + } + } + } + } + return procsToTerminate.Count; + } + + [DllImport("kernel32.dll")] + internal static extern ulong GetTickCount64(); + + private static int MillisLeftUntilDeadline(ulong deadline) + { + long diff = (long) (deadline - GetTickCount64()); + + if (diff < 0) + { + diff = 0; + } + else if (diff >= (long) Int32.MaxValue) + { + // Should not ever actually happen... + diff = DefaultGraceMillis; + } + + return (int) diff; + } + + private const int DefaultGraceMillis = 1000; + private const int MaxRounds = 2; + + // + // TerminateOrphanedConsoleApps + // + // This feature works around a bad interaction on Windows between: + // * a race condition between ctrl+c and console attachment, and + // * poor behavior when multiple processes want console input. + // + // This bad interaction is most likely to happen when the user has launched a process + // that is launching many, MANY more child processes (imagine a build system, for + // example): if the user types ctrl+c to cancel, all processes *currently attached* to + // the console will receive the ctrl+c signal (and presumably exit). However, there + // *may* have been some processes that had been created, but are not yet attached to + // the console--these grandchildren will have missed the ctrl+c signal (that's the + // race condition). If those grandchildren do not somehow figure out on their own that + // they should exit, the console enters a highly problematic state ("the borked + // state"): because pwsh's immediate child has exited, the shell will return to the + // prompt and wait for input. But those straggler granchildren are ALSO attached to + // the console... so when the user starts typing, who gets the input? + // + // It turns out that the console will just sort of randomly distribute pieces of input + // between all processes who want input--a straggler grandchild process might get a + // "key down" record, and then PSReadLine might get the corresponding "key up". This + // is obviously untenable; it makes the shell totally unusable. (The console team has + // been made aware, and there are several ideas of how to Do Better, but who knows + // when any of those will come to fruition.) + // + // To make matters worse: when returning to the prompt, PSReadLine disables ctrl+c + // signals (we prefer to handle those keys specially ourselves). So if you hit this + // situation with cmd.exe as your shell, you can just mash on ctrl+c for a while and + // kill all the stragglers manually; but if you have PSReadLine loaded, your shell is + // borked, and you are stuck. You CAN recover, IF you can track down and kill all the + // straggler processes manually. + // + // So when enabled, this feature does that for you: it kills all those straggler + // processes, right before we disable ctrl+c signals and wait for user input, ensuring + // that the user has a usable shell. + // + // Note that GUI processes do not attach to the console, so if you have launched + // notepad, for example, TerminateOrphanedConsoleApps will never even "see" it; they + // are immune from getting terminated. + // + // Q: But isn't terminating processes that we know nothing about kind of risky and + // extreme? + // + // A: Perhaps so... but consider the alternative: by definition, if you get into a + // situation where the TerminateOrphanedConsoleApps feature would actually kill + // anything, your shell will be Completely Broken. It's "them or us": allow the + // stragglers to live, but leave the user without their shell; or kill the + // stragglers and give the user their shell back. There is no middle ground. So + // when the TerminateOrphanedConsoleApps feature is enabled, that means the user + // has opted for "give me back my shell". + // + // Note that we do give stragglers a small grace period before terminating them, in + // case they are somehow just slow shutting down. But if you're wondering "should + // we make that grace period longer?", remember that another way to think of that + // period is "how long do I want the shell to potentially be unusable after + // displaying the prompt?" + // + // Q: What if the user *didn't* type ctrl+c? + // + // A: We don't care. When TerminateOrphanedConsoleApps is called, all we know is that + // the shell has displayed the prompt and believes it is time to wait for user + // input. Whether this situation came about because of a ctrl+c, or some other + // situation (for example, if the shell's immediate child crashed or was manually + // killed), if there are leftover straggler processes (console-attached + // grandchildren), the shell will be broken until they are gone, and thus we must + // take action (if the feature is enabled). + // + // Q: Should this really be baked into PSReadLine, or could we leave it to some other + // module to implement? (See: https://github.com/jazzdelightsme/ConsoleBouncer) + // + // A: We should have the option in PSReadLine. An external module can do something + // very *similar* to what we do here in PSReadLine, but not quite the same, and is + // strictly inferior. An external module would have to rely on receiving a ctrl+c + // signal, but "there was a ctrl+c signal" is NOT equivalent to "the shell is about + // to wait for input". For example, some child processes may depend on handling + // ctrl+c signals, *without* exiting (kd.exe / cdb.exe, for example). In such a + // case, control would not return to the shell, but an external module would have + // no way to know that (hence it is inferior). That could be worked around, but + // only clumsily--the user would have to have a way to tell the module "hey BTW + // please don't kill these ones, even though they will *look* like stragglers". + // + // And in fact, an external module solution may still be attractive to some users + // (and could safely be used with TerminateOrphanedConsoleApps enabled). Because + // the (external solution) ConsoleBouncer module reacts to ctrl+c signals, that + // makes it a bit more aggressive than what we do here: + // TerminateOrphanedConsoleApps only comes into play when control has returned to + // the shell, which might not be right away after the user types ctrl+c--there + // might be "Terminate batch job (Y/N)?" messages, etc. So if the user understands + // the limitations of the ConsoleBouncer module and has an environment where it + // would be suitable, they could still opt to use it to get much more responsive + // ctrl+c behavior. (A metaphor with a club: the PSReadLine built-in feature + // patiently waits for the host of a private party to leave before kicking the rest + // of the guests out; whereas the ConsoleBouncer, upon receipt of a ctrl+c signal, + // just clears the whole place out right away (which *might* not be the right thing + // to do, but you're paying them to be tough, not smart).) + // + private static void TerminateStragglers() + { + var procsToTerminate = new List(); + + // The theory for why more than one round might be needed is that the same race + // between process creation and console attachment that could cause lingering + // processes in the first place could cause us to need a second round of + // cleanup... but I've never actually seen more than one round be needed. Probably + // because in my specific scenario the process that was spawning processes got + // taken out with the original ctrl+c signal. + // + // If it takes more than a few rounds of cleanup, we may be in some kind of + // pathological situation, and we'll bow out. + int round = 0; + int killAttempts = 0; + + while (round++ < MaxRounds && + GatherStragglers(procsToTerminate) > 0) + { + // We'll give them up to GracePeriodMillis for them to exit on their + // own, in case they actually did receive the original ctrl+c, and are + // just a tad slow shutting down. + ulong deadline = GetTickCount64() + (ulong) DefaultGraceMillis; + + var notDeadYet = procsToTerminate.Where( + (p) => !p.WaitForExit(MillisLeftUntilDeadline(deadline))); + + foreach (var process in notDeadYet) + { + try + { + killAttempts++; + process.Kill(); + } + // Ignore problems; maybe it's gone already, maybe something else; + // whatever. + catch (InvalidOperationException) { } + catch (Win32Exception) { } + } + + foreach (var process in procsToTerminate) + { + process.Dispose(); + } + } // end retry loop + + // In forcible termination scenarios, if there was a child updating the terminal's + // progress state, it may be left stuck that way... we can clear that out. + // + // The preferred way to do that is with a VT sequence, but there's no way to know + // if the console we are attached to supports that sequence. If we are in Windows + // Terminal, we know we can use the VT sequence; else we'll fall back to the old + // (Win7-era?) COM API (which does the same thing). + uint consoleMode = GetConsoleOutputMode(); + if (ItLooksLikeWeAreInTerminal()) + { + // We can use the [semi-]standard OSC sequence: + // https://conemu.github.io/en/AnsiEscapeCodes.html#ConEmu_specific_OSC + if (0 != (consoleMode & (uint) ENABLE_VIRTUAL_TERMINAL_PROCESSING)) + { + // Use "bell" if we actually tried to whack anything. + string final = (killAttempts > 0) ? "\a" : "\x001b\\"; + Console.Write("\x001b]9;4;0;0" + final); + } + } + else + { + IntPtr hwnd = GetConsoleWindow(); + if (hwnd != IntPtr.Zero) + { + int ret = TaskbarProgress.SetProgressState(hwnd, TaskbarStates.NoProgress); + } + } + } + + private static uint _terminalOwnerThreadId; + + /// + /// This method helps to find the owner thread of the terminal window used by this pwsh instance, + /// by looking for a parent process whose ) is visible. + /// + /// The terminal process is not always the direct parent of the current process, but may be higher + /// in the process tree in case this pwsh process is a child of some other console process. + /// + /// This works well in Windows Terminal (with profile), IntelliJ and VSCode. + /// It doesn't work when PowerShell runs in conhost, or when it gets started from Start Menu with + /// Windows Terminal as the default terminal application (without profile). + /// + private static uint GetTerminalOwnerThreadId() + { + try + { + // The window handle returned by `GetConsoleWindow` is not the correct terminal/console window for us + // to query about the keyboard layout change. It's the window created for a console application, such + // as `cmd` or `pwsh`, so its owner process in those cases will be `cmd` or `pwsh`. + // + // When we are running with conhost, this window is visible, but it's not what we want and needs to be + // filtered out. When running with conhost, we want the window owned by the conhost. But unfortunately, + // there is no reliable way to get the conhost process that is associated with the current pwsh, since + // it's not in the parent chain of the process tree. + // So, this method is supposed to always fail when running with conhost. + IntPtr wrongHandle = GetConsoleWindow(); + + // Limit for parent process walk-up for not getting stuck in a loop (possible in case pid reuse). + const int iterationLimit = 20; + var process = Process.GetCurrentProcess(); + + for (int i = 0; i < iterationLimit; ++i) + { + if (process.ProcessName is "explorer") + { + // We've reached the root of the process tree. This can happen when PowerShell was started + // from Start Menu with Windows Terminal as the default terminal application. + // The `explorer` process has a visible window, but it doesn't help for getting the layout + // change. Again, we need to find the terminal window owner. + break; + } + + IntPtr mainWindowHandle = process.MainWindowHandle; + if (mainWindowHandle == wrongHandle) + { + // This can only happen when we are running with conhost. + // Break early because the terminal owner process is not in the parent chain in this scenario. + break; + } + + if (mainWindowHandle != IntPtr.Zero && IsWindowVisible(mainWindowHandle)) + { + // The window is visible, so it's likely the terminal window. + return GetWindowThreadProcessId(process.MainWindowHandle, out _); + } + + // When reaching here, the main window of the process: + // - doesn't exist, or + // - exists but invisible + // So, this is likely not a terminal process. + // Now we get its parent process and continue with the check. + int parentId = GetParentPid(process); + process = Process.GetProcessById(parentId); + } + } + catch (Exception) + { + // No access to the process, or the process is already dead. + // Either way, we cannot determine the owner thread of the terminal window. + } + + // We could not find the owner thread/process of the terminal window in following scenarios: + // 1. pwsh is running with conhost. + // This happens when conhost is set as the default terminal application, and a user starts pwsh + // from the Start Menu, or with `win+r` (run code) and etc. + // + // 2. pwsh is running with Windows Terminal, but was not started from a Windows Terminal profile. + // This happens when Windows Terminal is set as the default terminal application, and a user + // starts pwsh from the Start Menu, or with `win+r` (run code) and etc. + // The `WindowsTerminal` process is not in the parent process chain in this case. + // + // 3. pwsh's parent process chain is broken -- a parent was terminated so we cannot walk up the chain. + return 0; + } + + internal static IntPtr GetConsoleKeyboardLayout() + { + return GetKeyboardLayout(_terminalOwnerThreadId); + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("User32.dll", SetLastError = true)] + private static extern IntPtr GetKeyboardLayout(uint idThread); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint proccess); } diff --git a/PSReadLine/Position.cs b/PSReadLine/Position.cs index 32068da91..2aa32039c 100644 --- a/PSReadLine/Position.cs +++ b/PSReadLine/Position.cs @@ -102,23 +102,14 @@ private static int GetFirstNonBlankOfLogicalLinePos(int current) var beginningOfLine = GetBeginningOfLinePos(current); var newCurrent = beginningOfLine; + var buffer = _singleton._buffer; - while (newCurrent < _singleton._buffer.Length && IsVisibleBlank(newCurrent)) + while (newCurrent < buffer.Length && buffer.IsVisibleBlank(newCurrent)) { newCurrent++; } return newCurrent; } - - private static bool IsVisibleBlank(int newCurrent) - { - var c = _singleton._buffer[newCurrent]; - - // [:blank:] of vim's pattern matching behavior - // defines blanks as SPACE and TAB characters. - - return c == ' ' || c == '\t'; - } } } diff --git a/PSReadLine/Prediction.Entry.cs b/PSReadLine/Prediction.Entry.cs index a60d05e2e..643afa5d3 100644 --- a/PSReadLine/Prediction.Entry.cs +++ b/PSReadLine/Prediction.Entry.cs @@ -9,29 +9,66 @@ namespace Microsoft.PowerShell { public partial class PSConsoleReadLine { + /// + /// Represents a prediction source. + /// + private readonly struct SourceInfo + { + internal readonly string SourceName; + internal readonly int EndIndex; + internal readonly int PrevSourceEndIndex; + internal readonly int ItemCount; + + internal SourceInfo(string sourceName, int endIndex, int prevSourceEndIndex) + { + SourceName = sourceName; + int sourceWidth = LengthInBufferCells(SourceName); + if (sourceWidth > PredictionListView.SourceMaxWidth) + { + sourceWidth = PredictionListView.SourceMaxWidth - 1; + int sourceStrLen = SubstringLengthByCells(sourceName, sourceWidth); + SourceName = sourceName.Substring(0, sourceStrLen) + SuggestionEntry.Ellipsis; + } + + EndIndex = endIndex; + PrevSourceEndIndex = prevSourceEndIndex; + ItemCount = EndIndex - PrevSourceEndIndex; + } + } + /// /// This type represents an individual suggestion entry. /// private struct SuggestionEntry { + internal const char Ellipsis = '\u2026'; + internal const string HistorySource = "History"; + internal readonly Guid PredictorId; internal readonly uint? PredictorSession; internal readonly string Source; + internal readonly string ToolTip; internal readonly string SuggestionText; internal readonly int InputMatchIndex; + private string _listItemTextRegular; + private string _listItemTextSelected; + internal SuggestionEntry(string suggestion, int matchIndex) - : this(source: "History", predictorId: Guid.Empty, predictorSession: null, suggestion, matchIndex) + : this(source: HistorySource, predictorId: Guid.Empty, predictorSession: null, suggestion, tooltip: null, matchIndex) { } - internal SuggestionEntry(string source, Guid predictorId, uint? predictorSession, string suggestion, int matchIndex) + internal SuggestionEntry(string source, Guid predictorId, uint? predictorSession, string suggestion, string tooltip, int matchIndex) { Source = source; PredictorId = predictorId; PredictorSession = predictorSession; SuggestionText = suggestion; + ToolTip = tooltip; InputMatchIndex = matchIndex; + + _listItemTextRegular = _listItemTextSelected = null; } /// @@ -57,8 +94,19 @@ private static int DivideAndRoundUp(int dividend, int divisor) /// The highlighting sequences for a selected list item. internal string GetListItemText(int width, string input, string selectionHighlighting) { - const string ellipsis = "..."; - const int ellipsisLength = 3; + const int ellipsisLength = 1; + + if (selectionHighlighting is null) + { + if (_listItemTextRegular is not null) + { + return _listItemTextRegular; + } + } + else if (_listItemTextSelected is not null) + { + return _listItemTextSelected; + } // Calculate the 'SOURCE' portion to be rendered. int sourceStrLen = Source.Length; @@ -119,7 +167,7 @@ internal string GetListItemText(int width, string input, string selectionHighlig // The suggestion text doesn't contain the user input. int length = SubstringLengthByCells(SuggestionText, textWidth - ellipsisLength); line.Append(SuggestionText, 0, length) - .Append(ellipsis); + .Append(Ellipsis); break; } @@ -136,7 +184,7 @@ internal string GetListItemText(int width, string input, string selectionHighlig .Append(SuggestionText, 0, input.Length) .EndColorSection(selectionHighlighting) .Append(SuggestionText, input.Length, length - input.Length) - .Append(ellipsis); + .Append(Ellipsis); } else { @@ -149,7 +197,7 @@ internal string GetListItemText(int width, string input, string selectionHighlig int remainingLenInCells = textWidth - ellipsisLength - rightLenInCells; int length = SubstringLengthByCellsFromEnd(SuggestionText, input.Length - 1, remainingLenInCells); line.Append(_singleton._options.EmphasisColor) - .Append(ellipsis) + .Append(Ellipsis) .Append(SuggestionText, input.Length - length, length) .EndColorSection(selectionHighlighting) .Append(SuggestionText, input.Length, SuggestionText.Length - input.Length); @@ -162,11 +210,11 @@ internal string GetListItemText(int width, string input, string selectionHighlig int startIndex = input.Length - leftStrLen; int totalStrLen = SubstringLengthByCells(SuggestionText, startIndex, textWidth - ellipsisLength * 2); line.Append(_singleton._options.EmphasisColor) - .Append(ellipsis) + .Append(Ellipsis) .Append(SuggestionText, startIndex, leftStrLen) .EndColorSection(selectionHighlighting) .Append(SuggestionText, input.Length, totalStrLen - leftStrLen) - .Append(ellipsis); + .Append(Ellipsis); } } @@ -192,7 +240,7 @@ internal string GetListItemText(int width, string input, string selectionHighlig .Append(SuggestionText, InputMatchIndex, input.Length) .EndColorSection(selectionHighlighting) .Append(SuggestionText, rightStartindex, rightStrLen) - .Append(ellipsis); + .Append(Ellipsis); break; } @@ -201,7 +249,7 @@ internal string GetListItemText(int width, string input, string selectionHighlig { // Otherwise, if the (mid+right) portions take up to 2/3 of the text width, we just truncate the suggestion text at the beginning. int leftStrLen = SubstringLengthByCellsFromEnd(SuggestionText, InputMatchIndex - 1, textWidth - midRightLenInCells - ellipsisLength); - line.Append(ellipsis) + line.Append(Ellipsis) .Append(SuggestionText, InputMatchIndex - leftStrLen, leftStrLen) .Append(_singleton._options.EmphasisColor) .Append(SuggestionText, InputMatchIndex, input.Length) @@ -223,13 +271,13 @@ internal string GetListItemText(int width, string input, string selectionHighlig int leftStrLen = SubstringLengthByCellsFromEnd(SuggestionText, InputMatchIndex - 1, leftCellLen - ellipsisLength); int rightStrLen = SubstringLengthByCells(SuggestionText, rightStartindex, rigthCellLen - ellipsisLength); - line.Append(ellipsis) + line.Append(Ellipsis) .Append(SuggestionText, InputMatchIndex - leftStrLen, leftStrLen) .Append(_singleton._options.EmphasisColor) .Append(SuggestionText, InputMatchIndex, input.Length) .EndColorSection(selectionHighlighting) .Append(SuggestionText, rightStartindex, rightStrLen) - .Append(ellipsis); + .Append(Ellipsis); break; } @@ -249,7 +297,7 @@ internal string GetListItemText(int width, string input, string selectionHighlig line.Append(SuggestionText, 0, InputMatchIndex) .Append(_singleton._options.EmphasisColor) .Append(SuggestionText, InputMatchIndex, midLeftStrLen) - .Append(ellipsis) + .Append(Ellipsis) .Append(SuggestionText, rightStartindex - midRightStrLen, midRightStrLen) .EndColorSection(selectionHighlighting) .Append(SuggestionText, rightStartindex, SuggestionText.Length - rightStartindex); @@ -277,11 +325,11 @@ internal string GetListItemText(int width, string input, string selectionHighlig line.Append(SuggestionText, 0, InputMatchIndex) .Append(_singleton._options.EmphasisColor) .Append(SuggestionText, InputMatchIndex, midLeftStrLen) - .Append(ellipsis) + .Append(Ellipsis) .Append(SuggestionText, rightStartindex - midRightStrLen, midRightStrLen) .EndColorSection(selectionHighlighting) .Append(SuggestionText, rightStartindex, rightStrLen) - .Append(ellipsis); + .Append(Ellipsis); break; } @@ -298,11 +346,11 @@ internal string GetListItemText(int width, string input, string selectionHighlig int midRightStrLen = SubstringLengthByCellsFromEnd(SuggestionText, rightStartindex - 1, midRightCellLen); int leftStrLen = SubstringLengthByCellsFromEnd(SuggestionText, InputMatchIndex - 1, midRemainingLenInCells); - line.Append(ellipsis) + line.Append(Ellipsis) .Append(SuggestionText, InputMatchIndex - leftStrLen, leftStrLen) .Append(_singleton._options.EmphasisColor) .Append(SuggestionText, InputMatchIndex, midLeftStrLen) - .Append(ellipsis) + .Append(Ellipsis) .Append(SuggestionText, rightStartindex - midRightStrLen, midRightStrLen) .EndColorSection(selectionHighlighting) .Append(SuggestionText, rightStartindex, SuggestionText.Length - rightStartindex); @@ -324,15 +372,15 @@ internal string GetListItemText(int width, string input, string selectionHighlig int spacesNeeded = textWidth - midRemainingLenInCells * 3 - ellipsisLength * 3; string spaces = spacesNeeded > 0 ? Spaces(spacesNeeded) : string.Empty; - line.Append(ellipsis) + line.Append(Ellipsis) .Append(SuggestionText, InputMatchIndex - leftStrLen, leftStrLen) .Append(_singleton._options.EmphasisColor) .Append(SuggestionText, InputMatchIndex, midLeftStrLen) - .Append(ellipsis) + .Append(Ellipsis) .Append(SuggestionText, rightStartindex - midRightStrLen, midRightStrLen) .EndColorSection(selectionHighlighting) .Append(SuggestionText, rightStartindex, rightStrLen) - .Append(ellipsis) + .Append(Ellipsis) .Append(spaces); break; } @@ -351,13 +399,29 @@ internal string GetListItemText(int width, string input, string selectionHighlig else { line.Append(Source, 0, sourceStrLen) - .Append(ellipsis); + .Append(Ellipsis); } line.EndColorSection(selectionHighlighting) .Append(']'); - return line.ToString(); + if (selectionHighlighting is not null) + { + // Need to reset at the end if the selection highlighting is being applied. + line.Append(VTColorUtils.AnsiReset); + } + + string textForRendering = line.ToString(); + if (selectionHighlighting is null) + { + _listItemTextRegular = textForRendering; + } + else + { + _listItemTextSelected = textForRendering; + } + + return textForRendering; } } } diff --git a/PSReadLine/Prediction.Views.cs b/PSReadLine/Prediction.Views.cs index e61d396cd..a9145c432 100644 --- a/PSReadLine/Prediction.Views.cs +++ b/PSReadLine/Prediction.Views.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using System.Management.Automation.Subsystem.Prediction; using Microsoft.PowerShell.Internal; +using Microsoft.PowerShell.PSReadLine; namespace Microsoft.PowerShell { @@ -144,12 +145,9 @@ protected List GetHistorySuggestions(string input, int count) continue; } - if (results == null) - { - results = new List(capacity: count); - } - _cacheHistorySet.Add(line); + results ??= new List(capacity: count); + if (matchIndex == 0) { results.Add(new SuggestionEntry(line, matchIndex)); @@ -210,22 +208,60 @@ protected List GetPredictionResults() /// private class PredictionListView : PredictionViewBase { - internal const int ListMaxCount = 10; - internal const int ListMaxWidth = 100; + // Item count constants. + internal const int ListMaxCount = 50; + internal const int HistoryMaxCount = 10; + + // List view constants. + internal const int ListViewMaxHeight = 10; + internal const int ListViewMaxWidth = 100; + internal const int TooltipMaxHeight = 4; internal const int SourceMaxWidth = 15; - internal const int MinWindowWidth = 54; - internal const int MinWindowHeight = 15; + // Minimal window size. + internal const int MinWindowWidth = 50; + internal const int MinWindowHeight = 5; + // The items to be displayed in the list view. private List _listItems; - private int _listItemWidth; - private int _listItemHeight; + // Information about the sources of those items. + private List _sources; + // The index that is currently selected by user. private int _selectedIndex; + // Indicates to have the list view starts at the selected index. + private bool _renderFromSelected; + // Indicates a navigation update within the list view is pending. private bool _updatePending; + // The max list height to be used for rendering, which is auto-adjusted based on terminal height. + private int _maxViewHeight; + // The actual height of the list view that is currently rendered. + private int _listViewHeight; + // The max number of physical lines for rendering tooltip. + private int _maxTooltipHeight; + // The actual number of lines used for rendering the tooltip. + private int _tooltipHeight; + + // The actual width of the list view that is currently rendered. + private int _listViewWidth; + // An index pointing to the item that is shown in the first slot of the list view. + private int _listViewTop; + // An index pointing to the item right AFTER the one that is shown in the last slot of the list view. + private int _listViewEnd; + + // Indicates if we need to check on the height for each navigation in the list view. + private bool _checkOnHeight; + // To warn about that the terminal size is too small to display the list view. + private bool _warnAboutSize; + // Indicates if a warning message was displayed. + private bool _warningPrinted; + // Caches re-used when aggregating the suggestion results from predictors and history. + // Those caches help us avoid allocation on tons of short-lived collections. private List _cacheList1; private List _cacheList2; + private HashSet _cachedHistorySet; + private StringComparer _cachedComparer; /// /// Gets whether the current window size meets the minimum requirement for the List view to work. @@ -264,6 +300,23 @@ internal string SelectedItemText } } + /// + /// The tooltip of the currently selected item. + /// + internal string SelectedItemTooltip + { + get + { + if (_listItems == null || _selectedIndex == -1) + return null; + + if (_selectedIndex >= 0) + return _listItems[_selectedIndex].ToolTip; + + throw new InvalidOperationException("Unexpected '_selectedIndex' value: " + _selectedIndex); + } + } + internal PredictionListView(PSConsoleReadLine singleton) : base(singleton) { @@ -273,8 +326,46 @@ internal PredictionListView(PSConsoleReadLine singleton) internal override bool HasPendingUpdate => _updatePending; internal override bool HasActiveSuggestion => _listItems != null; + /// + /// Calculate the max width and height of the list view based on the current terminal size. + /// + private (int, int, int, bool) RefreshMaxViewSize() + { + var console = _singleton._console; + int maxListWidth = Math.Min(console.BufferWidth, ListViewMaxWidth); + + (int maxListHeight, int maxTooltipHeigth, bool moreCheck) = console.BufferHeight switch + { + > ListViewMaxHeight * 2 => (ListViewMaxHeight, TooltipMaxHeight, false), + > ListViewMaxHeight => (ListViewMaxHeight / 2, TooltipMaxHeight / 2, false), + _ => (ListViewMaxHeight / 3, TooltipMaxHeight / 3, true) + }; + + return (maxListWidth, maxListHeight, maxTooltipHeigth, moreCheck); + } + + /// + /// Check if the height becomes too small for the current rendering. + /// + private bool HeightIsTooSmall() + { + int tooltipLineCount = GetToolTipLineCountForHeightCheck(); + int physicalLineCountForBuffer = _singleton.EndOfBufferPosition().Y - _singleton._initialY + 1; + return _singleton._console.BufferHeight < physicalLineCountForBuffer + _maxViewHeight + tooltipLineCount + 1 /* one metadata line */; + } + + /// + /// Get suggestion results. + /// internal override void GetSuggestion(string userInput) { + if (_singleton._initialY < 0) + { + // Do not trigger list view prediction when the first line has already been scrolled up off the buffer. + // See https://github.com/PowerShell/PSReadLine/issues/3347 for an example where this may happen. + return; + } + bool inputUnchanged = string.Equals(_inputText, userInput, _singleton._options.HistoryStringComparison); if (!inputUnchanged && _selectedIndex > -1) { @@ -288,14 +379,16 @@ internal override void GetSuggestion(string userInput) if (!WindowSizeMeetsMinRequirement) { - // If the window size is too small for the list view to work, we just disable the list view. - Reset(); + // If the window size is too small to show the list view, we disable the list view and show a warning. + _warnAboutSize = true; return; } _inputText = userInput; + // Reset the list item selection. _selectedIndex = -1; - _listItemWidth = Math.Min(_singleton._console.BufferWidth, ListMaxWidth); + // Refresh the list view width and height in case the terminal was resized. + (_listViewWidth, _maxViewHeight, _maxTooltipHeight, _checkOnHeight) = RefreshMaxViewSize(); if (inputUnchanged) { @@ -306,6 +399,7 @@ internal override void GetSuggestion(string userInput) } _listItems?.Clear(); + _sources?.Clear(); try { @@ -316,7 +410,11 @@ internal override void GetSuggestion(string userInput) if (UseHistory) { - _listItems = GetHistorySuggestions(userInput, ListMaxCount); + _listItems = GetHistorySuggestions(userInput, HistoryMaxCount); + if (_listItems?.Count > 0) + { + _sources = new List() { new SourceInfo(SuggestionEntry.HistorySource, _listItems.Count - 1, prevSourceEndIndex: -1) }; + } } } catch @@ -341,6 +439,7 @@ private void AggregateSuggestions() try { _listItems ??= new List(); + _sources ??= new List(); _cacheList1 ??= new List(); // This list holds the total number of suggestions from each of the predictors. _cacheList2 ??= new List(); // This list holds the final number of suggestions that will be rendered for each of the predictors. @@ -369,7 +468,7 @@ private void AggregateSuggestions() // Assign the results of each plugin to the average slots. // Note that it's possible a plugin may return less results than the average slots, - // and in that case, the unused slots will be come remaining slots that are to be + // and in that case, the unused slots will become remaining slots which are to be // distributed again. for (int i = 0; i < pCount; i++) { @@ -405,13 +504,30 @@ private void AggregateSuggestions() break; } - more = _cacheList1[i] > 0; + more |= _cacheList1[i] > 0; } } if (hCount > 0) { - _listItems.RemoveRange(hCount, _listItems.Count - hCount); + if (hCount < _listItems.Count) + { + _listItems.RemoveRange(hCount, _listItems.Count - hCount); + _sources.Clear(); + _sources.Add(new SourceInfo(SuggestionEntry.HistorySource, hCount - 1, prevSourceEndIndex: -1)); + } + + if (_cachedComparer != _singleton._options.HistoryStringComparer) + { + // Create the cached history set if not yet, or re-create the set if case-sensitivity was changed by the user. + _cachedComparer = _singleton._options.HistoryStringComparer; + _cachedHistorySet = new HashSet(_cachedComparer); + } + + foreach (SuggestionEntry entry in _listItems) + { + _cachedHistorySet.Add(entry.SuggestionText); + } } int index = -1; @@ -428,19 +544,54 @@ private void AggregateSuggestions() break; } + int skipCount = 0; int num = _cacheList2[index]; - for (int i = 0; i < num; i++) + foreach (PredictiveSuggestion suggestion in item.Suggestions) { - string sugText = item.Suggestions[i].SuggestionText ?? string.Empty; + string sugText = suggestion.SuggestionText ?? string.Empty; + if (_cachedHistorySet?.Contains(sugText) == true) + { + // Skip the prediction result that is exactly the same as one of the history results. + skipCount++; + continue; + } + int matchIndex = sugText.IndexOf(_inputText, comparison); - _listItems.Add(new SuggestionEntry(item.Name, item.Id, item.Session, sugText, matchIndex)); + _listItems.Add(new SuggestionEntry(item.Name, item.Id, item.Session, sugText, suggestion.ToolTip, matchIndex)); + + if (--num == 0) + { + // Break after we've added the desired number of prediction results. + break; + } } - if (item.Session.HasValue) + // Get the number of prediction results that were actually put in the list after filtering out the duplicate ones. + int count = _cacheList2[index] - num; + if (count > 0) { - // Send feedback only if the mini-session id is specified. - // When it's not specified, we consider the predictor doesn't accept feedback. - _singleton._mockableMethods.OnSuggestionDisplayed(item.Id, item.Session.Value, num); + // If we had at least one source, we take the end index of the last source in the list. + int prevEndIndex = _sources.Count > 0 ? _sources[_sources.Count - 1].EndIndex : -1; + int endIndex = _listItems.Count - 1; + _sources.Add(new SourceInfo(_listItems[endIndex].Source, endIndex, prevEndIndex)); + + if (item.Session.HasValue && count > 0) + { + // Send feedback only if the mini-session id is specified and we truely have its results in the list to be rendered. + // When the mini-session id is not specified, we consider the predictor doesn't accept feedback. + // + // NOTE: when any duplicate results were skipped, the 'count' passed in here won't be accurate as it still includes + // those skipped ones. This is due to the limitation of the 'OnSuggestionDisplayed' interface method, which didn't + // assume any prediction results from a predictor could be filtered out at the initial design time. We will have to + // change the predictor interface to pass in accurate information, such as: + // void OnSuggestionDisplayed(Guid predictorId, uint session, int countOrIndex, int[] skippedIndices) + // + // However, an interface change has huge impacts. At least, a newer version of PSReadLine will stop working on the + // existing PowerShell 7+ versions. For this particular issue, the chance that it could happen is low and the impact + // of the inaccurate feedback is also low, so we should delay this interface change until another highly-demanded + // change to the interface is required in future (e.g. changes related to supporting OpenAI models). + _singleton._mockableMethods.OnSuggestionDisplayed(item.Id, item.Session.Value, count + skipCount); + } } } } @@ -449,12 +600,17 @@ private void AggregateSuggestions() { _cacheList1.Clear(); _cacheList2.Clear(); + _cachedHistorySet?.Clear(); } } if (_listItems?.Count > 0) { - _listItemHeight = Math.Min(_listItems.Count, ListMaxCount); + // Initialize the view window position here. + _listViewTop = 0; + _listViewEnd = Math.Min(_listItems.Count, _maxViewHeight); + _listViewHeight = _listViewEnd - _listViewTop; + _tooltipHeight = 0; } else { @@ -462,8 +618,24 @@ private void AggregateSuggestions() } } + /// + /// Generate the rendering text for the list view. + /// internal override void RenderSuggestion(List consoleBufferLines, ref int currentLogicalLine) { + if (_warnAboutSize || (_checkOnHeight && HeightIsTooSmall())) + { + _warningPrinted = true; + RenderWarningLine(NextBufferLine(consoleBufferLines, ref currentLogicalLine)); + + Reset(); + return; + } + else + { + _warningPrinted = false; + } + if (_updatePending) { _updatePending = false; @@ -473,36 +645,421 @@ internal override void RenderSuggestion(List consoleBufferLines, AggregateSuggestions(); } - if (_listItems == null) + if (_listItems is null) { return; } - for (int i = 0; i < _listItemHeight; i++) + // Create the metadata line. + RenderMetadataLine(NextBufferLine(consoleBufferLines, ref currentLogicalLine)); + + if (_selectedIndex >= 0) { - currentLogicalLine += 1; - if (currentLogicalLine == consoleBufferLines.Count) + // An item was selected, so update the view window accrodingly. + if (_renderFromSelected) + { + // Render from the selected index if there are enough items left for a page. + // If not, then render all remaining items plus a few from above the selected one, so as to render a full page. + _renderFromSelected = false; + int offset = _maxViewHeight - Math.Min(_listItems.Count - _selectedIndex, _maxViewHeight); + _listViewTop = offset > 0 ? Math.Max(0, _selectedIndex - offset) : _selectedIndex; + _listViewEnd = Math.Min(_listItems.Count, _listViewTop + _maxViewHeight); + } + else { - consoleBufferLines.Add(new StringBuilder(COMMON_WIDEST_CONSOLE_WIDTH)); + // - if the selected item is within the current top/end, then no need to move the list view window. + // - if the selected item is before the current top, then move the top to the selected item. + // - if the selected item is after the current end, then move the end to one beyond the selected item. + if (_selectedIndex < _listViewTop) + { + _listViewTop = _selectedIndex; + _listViewEnd = Math.Min(_listItems.Count, _selectedIndex + _maxViewHeight); + } + else if (_selectedIndex >= _listViewEnd) + { + _listViewEnd = _selectedIndex + 1; + _listViewTop = Math.Max(0, _listViewEnd - _maxViewHeight); + } } - bool itemSelected = i == _selectedIndex; - StringBuilder currentLineBuffer = consoleBufferLines[currentLogicalLine]; + _listViewHeight = _listViewEnd - _listViewTop; + _tooltipHeight = 0; + } + for (int i = _listViewTop; i < _listViewEnd; i++) + { + bool itemSelected = i == _selectedIndex; string selectionColor = itemSelected ? _singleton._options._listPredictionSelectedColor : null; - currentLineBuffer.Append( - _listItems[i].GetListItemText( - _listItemWidth, + SuggestionEntry entry = _listItems[i]; + + NextBufferLine(consoleBufferLines, ref currentLogicalLine) + .Append(entry.GetListItemText( + _listViewWidth, _inputText, selectionColor)); - if (itemSelected) + if (_singleton._options.ShowToolTips && itemSelected && !string.IsNullOrWhiteSpace(entry.ToolTip)) + { + _tooltipHeight = RenderTooltip(entry.ToolTip, consoleBufferLines, ref currentLogicalLine); + } + } + } + + /// + /// Generate the rendering text for the warning message. + /// + private void RenderWarningLine(StringBuilder buffer) + { + // Add italic text effect to the highlight color. + string highlightStyle = _singleton._options._listPredictionColor + "\x1b[3m"; + + buffer.Append(highlightStyle) + .Append(PSReadLineResources.WindowSizeTooSmallWarning) + .Append(VTColorUtils.AnsiReset); + } + + /// + /// Calculate the height of the list when warning was displayed. + /// + private int GetPesudoListHeightForWarningRendering() + { + int bufferWidth = _singleton._console.BufferWidth; + int lengthInCells = LengthInBufferCells(PSReadLineResources.WindowSizeTooSmallWarning); + int pesudoListHeight = lengthInCells / bufferWidth; + + if (lengthInCells % bufferWidth == 0) + { + pesudoListHeight--; + } + + return pesudoListHeight; + } + + /// + /// Calculate the number of tooltip lines rendered in the list view. + /// + private int GetToolTipLineCountForHeightCheck() + { + int tooltipLineCount = 0; + if (_singleton._options.ShowToolTips && _selectedIndex >= 0) + { + // When '_selectedIndex >= 0', this is an update to the list view triggered by navigation + // within the list, and thus '_listItems' is guaranteed to be not null. + string tooltip = _listItems[_selectedIndex].ToolTip; + if (!string.IsNullOrWhiteSpace(tooltip)) { - currentLineBuffer.Append(VTColorUtils.AnsiReset); + tooltipLineCount = _maxTooltipHeight; } } + + return tooltipLineCount; } + /// + /// Generate the rendering text for the metadata line. + /// + private void RenderMetadataLine(StringBuilder buffer) + { + // Add italic text effect to the highlight color. + string highlightStyle = _singleton._options._listPredictionColor + "\x1b[3m"; + string dimmedStyle = PSConsoleReadLineOptions.DefaultInlinePredictionColor; + string activeStyle = null; + + // Render the quick indicator. + buffer.Append(highlightStyle) + .Append('<') + .Append(_selectedIndex > -1 ? _selectedIndex + 1 : "-") + .Append('/') + .Append(_listItems.Count) + .Append('>') + .Append(VTColorUtils.AnsiReset); + + if (_listViewWidth < 60) + { + // We don't render the additional information about sources when the list view width is less than 60. + // Adjust the position of quick indicator a little bit in this case and call it done. + buffer.Insert(0, VTColorUtils.AnsiReset); + buffer.Insert(VTColorUtils.AnsiReset.Length, " ", count: 2); + return; + } + + /// + /// A helper function to avoid appending extra color VT sequences unnecessarily. + /// + static StringBuilder AppendColor(StringBuilder buffer, string colorToUse, ref string activeColor, out int nextCharPos) + { + if (activeColor is null) + { + buffer.Append(colorToUse); + } + else if (activeColor != colorToUse) + { + buffer.Append(VTColorUtils.AnsiReset).Append(colorToUse); + } + + activeColor = colorToUse; + nextCharPos = buffer.Length; + return buffer; + } + + // The list view width decides how to render the source information: + // - when width >= 80, we render upto 3 sources, + // - when width >= 60, we render upto 2 sources. + // The reason to select '80' and '60' here is because: + // - To render upto 3 sources, the maximum cell length that could be taken by both the total-count part and the extra-info part + // will be 75 (7+68), so we choose '80' as the minimal requirement for rendering 3 sources. + // - To render upto 2 sources, the maximum cell length that could be taken by both the total-count part and the extra-info part + // will be 55 (7+48), so we choose '60' as the minimal requirement for rendering 2 sources. + int maxSourceCount = _listViewWidth >= 80 ? 3 : 2; + int charPosition = buffer.Length; + int totalCountPartLength = buffer.Length - highlightStyle.Length - VTColorUtils.AnsiReset.Length; + int additionalPartLength = 0; + + int selected = -1; + int startFrom = 0; + + // If a list item was selected, calculate which source the list item belongs to and which source + // to start render for the additional information part. + if (_selectedIndex > -1) + { + for (int i = 0; i < _sources.Count; i++) + { + if (_selectedIndex <= _sources[i].EndIndex) + { + selected = i; + break; + } + } + + if (selected == 0) + { + startFrom = 0; + } + else if (selected == _sources.Count - 1) + { + startFrom = Math.Max(0, selected - (maxSourceCount - 1)); + } + else + { + startFrom = maxSourceCount == 3 ? selected - 1 : selected; + } + } + + // Start the extra information about the sources -- add the opening arrow bracket. + AppendColor(buffer, dimmedStyle, ref activeStyle, out _).Append('<'); + additionalPartLength++; + + // Add the prefix, continue to use dimmed color. + if (startFrom > 0) + { + buffer.Append(SuggestionEntry.Ellipsis).Append(' '); + additionalPartLength += 2; + } + + // Add the sources. + for (int i = 0; i < maxSourceCount; i++) + { + int index = startFrom + i; + if (index == _sources.Count) + { + break; + } + + if (i > 0) + { + // Add the separator. + buffer.Append(' '); + additionalPartLength++; + } + + int nextCharPos; + SourceInfo info = _sources[index]; + if (selected == index) + { + AppendColor(buffer, highlightStyle, ref activeStyle, out nextCharPos) + .Append(info.SourceName) + .Append('(') + .Append(_selectedIndex - info.PrevSourceEndIndex) + .Append('/') + .Append(info.ItemCount) + .Append(')'); + } + else + { + AppendColor(buffer, dimmedStyle, ref activeStyle, out nextCharPos) + .Append(info.SourceName) + .Append('(') + .Append(info.ItemCount) + .Append(')'); + } + + // Need to take into account multi-cell characters when calculating length. + additionalPartLength += LengthInBufferCells(buffer, nextCharPos, buffer.Length); + } + + // Add the suffix. + if (startFrom + maxSourceCount < _sources.Count) + { + AppendColor(buffer, dimmedStyle, ref activeStyle, out _) + .Append(' ') + .Append(SuggestionEntry.Ellipsis); + additionalPartLength += 2; + } + + // Add the closing arrow bracket. + AppendColor(buffer, dimmedStyle, ref activeStyle, out _) + .Append('>') + .Append(VTColorUtils.AnsiReset); + additionalPartLength++; + + // Lastly, insert the padding spaces. + int padding = _listViewWidth - additionalPartLength - totalCountPartLength; + buffer.Insert(charPosition, " ", padding); + } + + /// + /// Generate the rendering text for the tooltip. + /// + private int RenderTooltip(string tooltip, List consoleBufferLines, ref int currentLogicalLine) + { + const int LengthOfLeadingPart = 6; + const string IndicatorSymbol = ">>"; + const string MsgForViewAll = "( to view all)"; + const string MoreTextIndicator1 = " \u2026 "; + const string MoreTextIndicator2 = "\u2026 "; + const string BoldDimItalicStyle = "\x1b[1;2;3m"; + + bool first = true; + int newlineIndex = -1; + int cellCount = 0; + int start, end; + + StringBuilder buff = null; + bool moreToCome = false; + int windowWidth = _singleton._console.BufferWidth; + int linesLeft = _maxTooltipHeight; + + string tooltipStyle = _singleton._options._listPredictionTooltipColor; + if (tooltipStyle != PSConsoleReadLineOptions.DefaultInlinePredictionColor) + { + tooltipStyle += BoldDimItalicStyle; + } + + do + { + int startIndex = newlineIndex + 1; + + // This may happen when the tooltip ends with a newline character. + if (startIndex == tooltip.Length) + { + break; + } + + // Get the range of the current non-whitespace substring line. + newlineIndex = tooltip.IndexOf('\n', startIndex); + (start, end) = TrimSubstringInPlace(tooltip, startIndex, newlineIndex is -1 ? tooltip.Length - 1 : newlineIndex); + + // This may happen when the current substring contains whitespace characters only. + if (start is -1) + { + if (newlineIndex is -1) + { + // If we have reached the end, then we are done. + break; + } + + // Otherwise, we need to continue processing the next substring line. + continue; + } + + if (linesLeft is 0) + { + // More non-empty substring lines, but no more space for rendering. + moreToCome = true; + break; + } + + if (buff is not null) + { + // Append reset for the previous substring line. + buff.Append(VTColorUtils.AnsiReset); + } + + // Create a new buffer line for the current substring line. + linesLeft--; + buff = NextBufferLine(consoleBufferLines, ref currentLogicalLine); + + if (first) + { + first = false; + buff.Append(tooltipStyle) + .Append(' ', 3) + .Append(IndicatorSymbol) + .Append(' '); + } + else + { + buff.Append(tooltipStyle) + .Append(' ', LengthOfLeadingPart); + } + + cellCount = LengthOfLeadingPart; + for (; start <= end; start++) + { + char ch = tooltip[start]; + int charInCells = LengthInBufferCells(ch); + + cellCount += charInCells; + if (cellCount > windowWidth) + { + linesLeft--; + if (linesLeft is -1) + { + // More text from the current substring line, but no more space for rendering. + moreToCome = true; + cellCount -= charInCells; + break; + } + + cellCount = charInCells; + } + + buff.Append(ch); + } + } + while (linesLeft >= 0 && newlineIndex >= 0); + + if (moreToCome) + { + // Append the "( to view all)" at the end of the last line + string highlightStyle = _singleton._options._listPredictionColor + BoldDimItalicStyle; + int remainingCells = windowWidth - cellCount; + + if (remainingCells >= MsgForViewAll.Length + MoreTextIndicator1.Length) + { + buff.Append(MoreTextIndicator1); + } + else + { + int length = MsgForViewAll.Length + MoreTextIndicator2.Length - remainingCells; + int buffIndex = buff.Length - length; + + buff.Remove(buffIndex, length); + buff.Append(MoreTextIndicator2); + } + + buff.Append(VTColorUtils.AnsiReset) + .Append(highlightStyle) + .Append(MsgForViewAll); + } + + buff.Append(VTColorUtils.AnsiReset); + return _maxTooltipHeight - (linesLeft > 0 ? linesLeft : 0); + } + + /// + /// Trigger the feedback about a suggestion was accepted. + /// internal override void OnSuggestionAccepted() { if (!UsePlugin) @@ -522,24 +1079,42 @@ internal override void OnSuggestionAccepted() } } + /// + /// Clear the list view. + /// internal override void Clear(bool cursorAtEol) { - if (_listItems == null) { return; } + if (_listItems == null && !_warningPrinted) + { + return; + } + + int listHeight = _warningPrinted + ? GetPesudoListHeightForWarningRendering() + : _listViewHeight + _tooltipHeight; int top = cursorAtEol ? _singleton._console.CursorTop - : _singleton.ConvertOffsetToPoint(_inputText.Length).Y; + : _singleton.EndOfBufferPosition().Y; - _singleton.WriteBlankLines(top + 1, _listItemHeight); + _warningPrinted = false; + _singleton.WriteBlankLines(top + 1, listHeight + 1 /* plus 1 to include the metadata line */); Reset(); } + /// + /// Reset all the list view states. + /// internal override void Reset() { base.Reset(); + + _sources = null; _listItems = null; - _listItemWidth = _listItemHeight = _selectedIndex = -1; - _updatePending = false; + _maxViewHeight = _maxTooltipHeight = -1; + _listViewWidth = _listViewHeight = _tooltipHeight = -1; + _listViewTop = _listViewEnd = _selectedIndex = -1; + _warnAboutSize = _checkOnHeight = _updatePending = _renderFromSelected = false; } /// @@ -548,8 +1123,12 @@ internal override void Reset() /// internal void UpdateListSelection(int move) { + // While moving around the list, we want to go back to the original input when we move one down + // after the last item, or move one up before the first item in the list. + // So, we can imagine a virtual list constructed by inserting the original input at the index 0 + // of the real list. int virtualItemIndex = _selectedIndex + 1; - int virtualItemCount = _listItemHeight + 1; + int virtualItemCount = _listItems.Count + 1; _updatePending = true; virtualItemIndex += move; @@ -567,6 +1146,107 @@ internal void UpdateListSelection(int move) _selectedIndex = virtualItemIndex % virtualItemCount + virtualItemCount - 1; } } + + /// + /// Page up/down within the list view. + /// Update the index of the selected item based on and . + /// + internal bool UpdateListByPaging(bool pageUp, int num) + { + if (_selectedIndex == -1) + { + return false; + } + + int oldSelectedIndex = _selectedIndex; + int lastItemIndex = _listItems.Count - 1; + + for (int i = 0; i < num; i++) + { + if (pageUp) + { + if (_selectedIndex == 0) + { + break; + } + + // Do one page up. + _selectedIndex = _selectedIndex == _listViewEnd - 1 + ? _listViewTop + : Math.Max(0, _selectedIndex - (_maxViewHeight - 1)); + } + else + { + if (_selectedIndex == lastItemIndex) + { + break; + } + + // Do one page down. + _selectedIndex = _selectedIndex == _listViewTop + ? _listViewEnd - 1 + : Math.Min(lastItemIndex, _selectedIndex + (_maxViewHeight - 1)); + } + } + + if (_selectedIndex != oldSelectedIndex) + { + // The selected item is changed, so we need to update the rendering. + _updatePending = true; + return true; + } + + return false; + } + + /// + /// Loop up/down through the sources rendered in the list view. + /// Update the index of the selected item based on and . + /// + internal bool UpdateListByLoopingSources(bool jumpUp, int num) + { + if (_selectedIndex == -1) + { + return false; + } + + int selectedSource = -1; + for (int i = 0; i < _sources.Count; i++) + { + if (_selectedIndex <= _sources[i].EndIndex) + { + selectedSource = i; + break; + } + } + + int oldSelectedIndex = _selectedIndex; + for (int i = 0; i < num; i++) + { + if (jumpUp) + { + _selectedIndex = selectedSource == 0 + ? _sources[_sources.Count - 1].PrevSourceEndIndex + 1 + : _sources[selectedSource - 1].PrevSourceEndIndex + 1; + } + else + { + _selectedIndex = selectedSource == _sources.Count - 1 + ? 0 + : _sources[selectedSource].EndIndex + 1; + } + } + + if (_selectedIndex != oldSelectedIndex) + { + // The selected item is changed, so we need to update the rendering. + _updatePending = true; + _renderFromSelected = true; + return true; + } + + return false; + } } /// @@ -596,9 +1276,29 @@ internal override void GetSuggestion(string userInput) { _inputText = userInput; - if (_suggestionText == null || _suggestionText.Length <= userInput.Length || - _lastInputText.Length > userInput.Length || - !_suggestionText.StartsWith(userInput, _singleton._options.HistoryStringComparison)) + string currentSugText = null; + bool needToRefresh = _suggestionText == null + || _suggestionText.Length <= userInput.Length + || _lastInputText.Length > userInput.Length + || !_suggestionText.StartsWith(userInput, _singleton._options.HistoryStringComparison); + + + // The current suggestion was from history and it still applies to the new input. However, the plugin is in use, + // so we may need to force refreshing in case the plugin gives more relevant suggestion for the new input. This + // is because we favor plugin over history in the inline view. + if (!needToRefresh && _predictorId == Guid.Empty && UsePlugin) + { + // We generally want to force refreshing in this case, with only one exception -- the user accepted the next + // word from the current history suggestion. That means the user is interested in the current suggestion and + // thus we should keep on using. + needToRefresh = !_alreadyAccepted; + _alreadyAccepted = false; + + // We can reuse the current suggestion text for history to avoid an unnecessary search. + currentSugText = _suggestionText; + } + + if (needToRefresh) { _alreadyAccepted = false; _suggestionText = null; @@ -611,7 +1311,7 @@ internal override void GetSuggestion(string userInput) if (UseHistory) { - _suggestionText = GetOneHistorySuggestion(userInput); + _suggestionText = currentSugText ?? GetOneHistorySuggestion(userInput); _predictorId = Guid.Empty; _predictorSession = null; } @@ -695,7 +1395,7 @@ internal override void RenderSuggestion(List consoleBufferLines, // The whole suggestion text cannot fit in the console buffer without having part of it scrolled up off the buffer. // We truncate the end part and append ellipsis. - // We need to truncate 4 buffer cells ealier (just to be safe), so we have enough room to add the ellipsis. + // We need to truncate 4 buffer cells earlier (just to be safe), so we have enough room to add the ellipsis. int lenFromEnd = SubstringLengthByCellsFromEnd(_suggestionText, length - 1, countOfCells: 4); totalLength = length - lenFromEnd; if (totalLength <= inputLength) @@ -716,6 +1416,7 @@ internal override void RenderSuggestion(List consoleBufferLines, StringBuilder currentLineBuffer = consoleBufferLines[currentLogicalLine]; currentLineBuffer + .Append(VTColorUtils.AnsiReset) .Append(_singleton._options._inlinePredictionColor) .Append(_suggestionText, inputLength, _renderedLength - inputLength); @@ -729,15 +1430,14 @@ internal override void RenderSuggestion(List consoleBufferLines, internal override void OnSuggestionAccepted() { - if (!UsePlugin) + if (_alreadyAccepted) { return; } - if (!_alreadyAccepted && _suggestionText != null && _predictorSession.HasValue) + _alreadyAccepted = true; + if (_suggestionText != null && _predictorSession.HasValue) { - _alreadyAccepted = true; - // Send feedback only if the mini-session id is specified. // When it's not specified, we consider the predictor doesn't accept feedback. _singleton._mockableMethods.OnSuggestionAccepted(_predictorId, _predictorSession.Value, _suggestionText); @@ -813,12 +1513,12 @@ internal int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimit } int i = currentIndex; - if (!_singleton.InWord(_suggestionText[i], wordDelimiters)) + if (!Character.IsInWord(_suggestionText[i], wordDelimiters)) { // Scan to end of current non-word region while (++i < _suggestionText.Length) { - if (_singleton.InWord(_suggestionText[i], wordDelimiters)) + if (Character.IsInWord(_suggestionText[i], wordDelimiters)) { break; } @@ -829,7 +1529,7 @@ internal int FindForwardSuggestionWordPoint(int currentIndex, string wordDelimit { while (++i < _suggestionText.Length) { - if (!_singleton.InWord(_suggestionText[i], wordDelimiters)) + if (!Character.IsInWord(_suggestionText[i], wordDelimiters)) { if (_suggestionText[i] == ' ') { diff --git a/PSReadLine/Prediction.cs b/PSReadLine/Prediction.cs index 53f6b8437..bd1448b9a 100644 --- a/PSReadLine/Prediction.cs +++ b/PSReadLine/Prediction.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; using System.Management.Automation; +using System.Management.Automation.Runspaces; using System.Management.Automation.Language; using System.Management.Automation.Subsystem.Prediction; using System.Diagnostics.CodeAnalysis; @@ -16,8 +18,44 @@ namespace Microsoft.PowerShell { public partial class PSConsoleReadLine { - private const string PSReadLine = "PSReadLine"; - private static PredictionClient s_predictionClient = new(PSReadLine, PredictionClientKind.Terminal); + private const string DefaultName = "PSReadLine"; + private static readonly PredictionClient s_predictionClient = new(DefaultName, PredictionClientKind.Terminal); + private static PropertyInfo s_pCurrentLocation = null; + + /// + /// Initialize the objects for those public settable properties newly added to + /// . + /// + private static void InitializePropertyInfo() + { + Version ver = typeof(PSObject).Assembly.GetName().Version; + if (ver.Major < 7 || ver.Minor < 4) + { + return; + } + + Type pcType = typeof(PredictionClient); + // Property added in 7.4 + s_pCurrentLocation = pcType.GetProperty("CurrentLocation"); + } + + /// + /// New public settable properties may be added to the type as it evolves to + /// offer more helpful context information. We dynamically set those properties here to avoid any backward + /// compatibility issues. + /// + private static void UpdatePredictionClient(Runspace runspace, EngineIntrinsics engineIntrinsics) + { + // Set the current location if the 'CurrentLocation' property exists. + if (s_pCurrentLocation is not null) + { + // Set the current location if it's a local Runspace. Otherwise, set it to null. + object path = runspace is null || runspace.RunspaceIsRemote + ? null + : engineIntrinsics?.SessionState.Path.CurrentLocation; + s_pCurrentLocation.SetValue(s_predictionClient, path); + } + } // Stub helper methods so prediction can be mocked [ExcludeFromCodeCoverage] @@ -144,6 +182,21 @@ public static void PreviousSuggestion(ConsoleKeyInfo? key = null, object arg = n UpdateListSelection(numericArg); } + /// + /// Show the tooltip of the currently selected list item in the full view. + /// + public static void ShowFullPredictionTooltip(ConsoleKeyInfo? key = null, object arg = null) + { + if (_singleton._prediction.ActiveView is PredictionListView listView && listView.HasActiveSuggestion) + { + string tooltip = listView.SelectedItemTooltip; + if (!string.IsNullOrWhiteSpace(tooltip)) + { + _singleton._mockableMethods.RenderFullHelp(tooltip, regexPatternToScrollTo: null); + } + } + } + /// /// Implementation for updating the selected item in list view. /// @@ -163,6 +216,42 @@ private static bool UpdateListSelection(int numericArg) return false; } + private static bool UpdateListByPaging(bool pageUp, int numericArg) + { + if (_singleton._prediction.ActiveView is PredictionListView listView && listView.HasActiveSuggestion) + { + // Ignore the visual selection. + _singleton._visualSelectionCommandCount = 0; + + if (listView.UpdateListByPaging(pageUp, numericArg)) + { + ReplaceSelection(listView.SelectedItemText); + } + + return true; + } + + return false; + } + + private static bool UpdateListByLoopingSources(bool jumpUp, int numericArg) + { + if (_singleton._prediction.ActiveView is PredictionListView listView && listView.HasActiveSuggestion) + { + // Ignore the visual selection. + _singleton._visualSelectionCommandCount = 0; + + if (listView.UpdateListByLoopingSources(jumpUp, numericArg)) + { + ReplaceSelection(listView.SelectedItemText); + } + + return true; + } + + return false; + } + /// /// Replace current buffer with the selected list item text. /// The replacement is done in a way that allows further selection updates for the same list view diff --git a/PSReadLine/PublicAPI.cs b/PSReadLine/PublicAPI.cs index 3e9696686..e4e311e80 100644 --- a/PSReadLine/PublicAPI.cs +++ b/PSReadLine/PublicAPI.cs @@ -92,6 +92,7 @@ public static void Insert(char c) /// String to insert public static void Insert(string s) { + s = s.Replace("\r\n", "\n"); _singleton.SaveEditItem(EditItemInsertString.Create(s, _singleton._current)); // Use Append if possible because Insert at end makes StringBuilder quite slow. diff --git a/PSReadLine/ReadLine.cs b/PSReadLine/ReadLine.cs index e76c78d66..da890bbba 100644 --- a/PSReadLine/ReadLine.cs +++ b/PSReadLine/ReadLine.cs @@ -25,23 +25,18 @@ namespace Microsoft.PowerShell { - [SuppressMessage("Microsoft.Design", "CA1032:ImplementStandardExceptionConstructors")] - [SuppressMessage("Microsoft.Usage", "CA2237:MarkISerializableTypesWithSerializable")] class ExitException : Exception { } + class LineAcceptedException : Exception { } public partial class PSConsoleReadLine : IPSConsoleReadLineMockableMethods { private const int ConsoleExiting = 1; - private const int CancellationRequested = 2; - // *must* be initialized in the static ctor // because the static member _clipboard depends upon it // for its own initialization private static readonly PSConsoleReadLine _singleton; - private static readonly CancellationToken _defaultCancellationToken = new CancellationTokenSource().Token; - // This is used by PowerShellEditorServices (the backend of the PowerShell VSCode extension) // so that it can call PSReadLine from a delegate and not hit nested pipeline issues. #pragma warning disable CS0649 @@ -49,6 +44,11 @@ public partial class PSConsoleReadLine : IPSConsoleReadLineMockableMethods #pragma warning restore CS0649 private bool _delayedOneTimeInitCompleted; + // This is used by AIShell to check if PSReadLine is initialized and ready to render. + #pragma warning disable CS0414 + private bool _readLineReady; + #pragma warning restore CS0414 + private bool _lineAcceptedExceptionThrown; private IPSConsoleReadLineMockableMethods _mockableMethods; private IConsole _console; @@ -143,17 +143,7 @@ private void ReadOneOrMoreKeys() } while (_charMap.KeyAvailable) { - ConsoleKeyInfo keyInfo = _charMap.ReadKey(); - if (_cancelReadCancellationToken.IsCancellationRequested) - { - // If PSReadLine is running under a host that can cancel it, the - // cancellation will come at a time when ReadKey is stuck waiting for input. - // The next key press will be used to force it to return, and so we want to - // discard this key since we were already canceled. - continue; - } - - var key = PSKeyInfo.FromConsoleKeyInfo(keyInfo); + var key = PSKeyInfo.FromConsoleKeyInfo(_charMap.ReadKey()); _lastNKeys.Enqueue(key); _queuedKeys.Enqueue(key); } @@ -170,10 +160,6 @@ private void ReadKeyThreadProc() break; ReadOneOrMoreKeys(); - if (_cancelReadCancellationToken.IsCancellationRequested) - { - continue; - } // One or more keys were read - let ReadKey know we're done. _keyReadWaitHandle.Set(); @@ -193,9 +179,18 @@ internal static PSKeyInfo ReadKey() // By waiting for a key on a different thread, our pipeline execution thread // (the thread ReadLine is called from) avoid being blocked in code that can't // be unblocked and instead blocks on events we control. - - // First, set an event so the thread to read a key actually attempts to read a key. - _singleton._readKeyWaitHandle.Set(); + if (_singleton._lineAcceptedExceptionThrown) + { + // If we threw a 'LineAcceptedException', it means that "AcceptLine" was called within an 'OnIdle' handler the last time + // this method was called, and thus we didn't wait for '_keyReadWaitHandle' to be signalled by the 'readkey thread'. + // In this case, we don't want to signal '_readKeyWaitHandle' again as the 'readkey thread' already got a chance to run. + _singleton._lineAcceptedExceptionThrown = false; + } + else + { + // Set an event so the 'readkey thread' actually attempts to read a key. + _singleton._readKeyWaitHandle.Set(); + } int handleId; System.Management.Automation.PowerShell ps = null; @@ -208,7 +203,6 @@ internal static PSKeyInfo ReadKey() // - a key is pressed // - the console is exiting // - 300ms timeout - to process events if we're idle - // - ReadLine cancellation is requested externally handleId = WaitHandle.WaitAny(_singleton._requestKeyWaitHandles, 300); if (handleId != WaitHandle.WaitTimeout) { @@ -224,24 +218,29 @@ internal static PSKeyInfo ReadKey() // If we timed out, check for event subscribers (which is just // a hint that there might be an event waiting to be processed.) var eventSubscribers = _singleton._engineIntrinsics?.Events.Subscribers; + int bufferLen = _singleton._buffer.Length; if (eventSubscribers?.Count > 0) { bool runPipelineForEventProcessing = false; foreach (var sub in eventSubscribers) { - if (sub.SourceIdentifier.Equals(PSEngineEvent.OnIdle, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(sub.SourceIdentifier, PSEngineEvent.OnIdle, StringComparison.OrdinalIgnoreCase)) { // If the buffer is not empty, let's not consider we are idle because the user is in the middle of typing something. - if (_singleton._buffer.Length > 0) + if (bufferLen > 0) { continue; } - // There is an OnIdle event subscriber and we are idle because we timed out and the buffer is empty. - // Normally PowerShell generates this event, but PowerShell assumes the engine is not idle because - // it called PSConsoleHostReadLine which isn't returning. So we generate the event instead. + // There is an 'OnIdle' event subscriber and we are idle because we timed out and the buffer is empty. + // Normally PowerShell generates this event, but now PowerShell assumes the engine is not idle because + // it called 'PSConsoleHostReadLine' which isn't returning. So we generate the event instead. runPipelineForEventProcessing = true; - _singleton._engineIntrinsics.Events.GenerateEvent(PSEngineEvent.OnIdle, null, null, null); + _singleton._engineIntrinsics.Events.GenerateEvent( + PSEngineEvent.OnIdle, + sender: null, + args: null, + extraData: null); // Break out so we don't genreate more than one 'OnIdle' event for a timeout. break; @@ -257,18 +256,49 @@ internal static PSKeyInfo ReadKey() if (ps == null) { ps = System.Management.Automation.PowerShell.Create(RunspaceMode.CurrentRunspace); - ps.AddScript("0", useLocalScope: true); + ps.AddScript("[System.Diagnostics.DebuggerHidden()]param() 0", useLocalScope: true); } - // To detect output during possible event processing, see if the cursor moved - // and rerender if so. - var console = _singleton._console; - var y = console.CursorTop; + // To detect output during possible event processing, see if the cursor moved and rerender if so. + int cursorTop = _singleton._console.CursorTop; + + // Start the pipeline to process events. ps.Invoke(); - if (y != console.CursorTop) + + // Check if any event handler writes console output to the best of our effort, and adjust the initial coordinates in that case. + // + // I say "to the best of our effort" because the delegate handler for an event will mostly run on a background thread, and thus + // there is no guarantee about when the delegate would finish. So in an extreme case, there could be race conditions in console + // read/write: we are reading 'CursorTop' while the delegate is writing console output on a different thread. + // There is no much we can do about that extreme case. However, our focus here is the 'OnIdle' event, and its handler is usually + // a script block, which will run within the 'ps.Invoke()' call above. + // + // We detect new console output by checking if cursor top changed, but handle a very special case: an event handler changed our + // buffer, by calling 'Insert' for example. + // I know only checking on buffer length change doesn't cover the case where buffer changed but the length is the same. However, + // we mainly want to cover buffer changes made by an 'OnIdle' event handler, and we trigger 'OnIdle' event only if the buffer is + // empty. So, this check is efficient and good enough for that main scenario. + // When our buffer was changed by an event handler, we assume that was all the event handler did and there was no direct console + // output. So, we adjust the initial coordinates only if cursor top changed but there was no buffer change. + int newCursorTop = _singleton._console.CursorTop; + int newBufferLen = _singleton._buffer.Length; + if (cursorTop != newCursorTop && bufferLen == newBufferLen) + { + _singleton._initialY = newCursorTop; + if (bufferLen > 0) + { + _singleton.Render(); + } + } + + if (_singleton._inputAccepted && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - _singleton._initialY = console.CursorTop; - _singleton.Render(); + // 'AcceptLine' was called by an 'OnIdle' handler. + // In this case, we only want to break out of the loop and accept the current input on Windows, because + // accepting input without a keystroke would leave the 'readkey thread' blocked on the 'ReadKey()' call, + // and that will make all subsequent writes to console blocked on Linux and macOS until a key is pressed. + _singleton._lineAcceptedExceptionThrown = true; + throw new LineAcceptedException(); } } } @@ -292,10 +322,12 @@ internal static PSKeyInfo ReadKey() throw new OperationCanceledException(); } - if (handleId == CancellationRequested) + if (_singleton._cancelReadCancellationToken.IsCancellationRequested) { - // ReadLine was cancelled. Save the current line to be restored next time ReadLine - // is called, clear the buffer and throw an exception so we can return an empty string. + // ReadLine was cancelled. Dequeue the dummy input sent by the host, save the current + // line to be restored next time ReadLine is called, clear the buffer and throw an + // exception so we can return an empty string. + _singleton._queuedKeys.Dequeue(); _singleton.SaveCurrentLine(); _singleton._getNextHistoryIndex = _singleton._history.Count; _singleton._current = 0; @@ -331,9 +363,7 @@ private void PrependQueuedKeys(PSKeyInfo key) /// The complete command line. public static string ReadLine(Runspace runspace, EngineIntrinsics engineIntrinsics, bool? lastRunStatus) { - // Use a default cancellation token instead of CancellationToken.None because the - // WaitHandle is shared and could be triggered accidently. - return ReadLine(runspace, engineIntrinsics, _defaultCancellationToken, lastRunStatus); + return ReadLine(runspace, engineIntrinsics, CancellationToken.None, lastRunStatus); } /// @@ -395,8 +425,8 @@ public static string ReadLine( _singleton.Initialize(runspace, engineIntrinsics); } + _singleton._readLineReady = true; _singleton._cancelReadCancellationToken = cancellationToken; - _singleton._requestKeyWaitHandles[2] = cancellationToken.WaitHandle; return _singleton.InputLoop(); } catch (OperationCanceledException) @@ -468,6 +498,8 @@ public static string ReadLine( } finally { + _singleton._readLineReady = false; + try { // If we are closing, restoring the old console settings isn't needed, @@ -522,8 +554,16 @@ private string InputLoop() // window resizing cannot and shouldn't happen within the processing of a given keybinding. _handlePotentialResizing = true; - var key = ReadKey(); - ProcessOneKey(key, _dispatchTable, ignoreIfNoAction: false, arg: null); + try + { + var key = ReadKey(); + ProcessOneKey(key, _dispatchTable, ignoreIfNoAction: false, arg: null); + } + catch (LineAcceptedException) + { + Debug.Assert(_inputAccepted, "LineAcceptedException should only be thrown when input was accepted within an 'OnIdle' handler."); + } + if (_inputAccepted) { _acceptedCommandLine = _buffer.ToString(); @@ -672,6 +712,7 @@ static PSConsoleReadLine() { _singleton = new PSConsoleReadLine(); _viRegister = new ViRegister(_singleton); + InitializePropertyInfo(); } private PSConsoleReadLine() @@ -694,7 +735,7 @@ private PSConsoleReadLine() { try { - var results = ps.AddScript("$Host", useLocalScope: true).Invoke(); + var results = ps.AddScript("[System.Diagnostics.DebuggerHidden()]param() $Host", useLocalScope: true).Invoke(); PSHost host = results.Count == 1 ? results[0] : null; hostName = host?.Name; } @@ -702,11 +743,9 @@ private PSConsoleReadLine() { } } - if (hostName == null) - { - hostName = PSReadLine; - } - _options = new PSConsoleReadLineOptions(hostName); + + bool usingLegacyConsole = _console is PlatformWindows.LegacyWin32Console; + _options = new PSConsoleReadLineOptions(hostName ?? DefaultName, usingLegacyConsole); _prediction = new Prediction(this); SetDefaultBindings(_options.EditMode); } @@ -716,6 +755,16 @@ private void Initialize(Runspace runspace, EngineIntrinsics engineIntrinsics) _engineIntrinsics = engineIntrinsics; _runspace = runspace; + // The directory separator to be used for tab completion may change depending on + // whether we are working with a remote Runspace. + // So, we always set it to null for every call into 'PSConsoleReadLine.ReadLine', + // and do the real initialization when tab completion is triggered for the first + // time during that call. + _directorySeparator = null; + + // Update the client instance per every call to PSReadLine. + UpdatePredictionClient(runspace, engineIntrinsics); + if (!_delayedOneTimeInitCompleted) { DelayedOneTimeInitialize(); @@ -795,7 +844,7 @@ private void Initialize(Runspace runspace, EngineIntrinsics engineIntrinsics) private void DelayedOneTimeInitialize() { // Delayed initialization is needed so that options can be set - // after the constuctor but have an affect before the user starts + // after the constructor but have an affect before the user starts // editing their first command line. For example, if the user // specifies a custom history save file, we don't want to try reading // from the default one. @@ -877,7 +926,7 @@ private void DelayedOneTimeInitialize() _readKeyWaitHandle = new AutoResetEvent(false); _keyReadWaitHandle = new AutoResetEvent(false); _closingWaitHandle = new ManualResetEvent(false); - _requestKeyWaitHandles = new WaitHandle[] {_keyReadWaitHandle, _closingWaitHandle, null}; + _requestKeyWaitHandles = new WaitHandle[] {_keyReadWaitHandle, _closingWaitHandle}; _threadProcWaitHandles = new WaitHandle[] {_readKeyWaitHandle, _closingWaitHandle}; // This is for a "being hosted in an alternate appdomain scenario" (the @@ -1017,16 +1066,28 @@ public static void DigitArgument(ConsoleKeyInfo? key = null, object arg = null) public static void InvokePrompt(ConsoleKeyInfo? key = null, object arg = null) { var console = _singleton._console; - console.CursorVisible = false; if (arg is int newY) { + if (newY < 0 || newY >= console.BufferHeight) + { + throw new ArgumentOutOfRangeException(nameof(arg)); + } + + console.CursorVisible = false; console.SetCursorPosition(0, newY); } else { newY = _singleton._initialY - _singleton._options.ExtraPromptLineCount; + // Silently return if user has implicitly requested an impossible prompt invocation. + if (newY < 0) + { + return; + } + + console.CursorVisible = false; console.SetCursorPosition(0, newY); // We need to rewrite the prompt, so blank out everything from a previous prompt invocation diff --git a/PSReadLine/ReadLine.vi.cs b/PSReadLine/ReadLine.vi.cs index c39932c75..571f06f5c 100644 --- a/PSReadLine/ReadLine.vi.cs +++ b/PSReadLine/ReadLine.vi.cs @@ -572,6 +572,12 @@ public static void ViInsertAtEnd(ConsoleKeyInfo? key = null, object arg = null) /// Append from the current line position. /// public static void ViInsertWithAppend(ConsoleKeyInfo? key = null, object arg = null) + { + _singleton._groupUndoHelper.StartGroup(ViInsertWithAppend, arg); + ViInsertWithAppendImpl(key, arg); + } + + private static void ViInsertWithAppendImpl(ConsoleKeyInfo? key = null, object arg = null) { ViInsertMode(key, arg); ForwardChar(key, arg); @@ -1304,7 +1310,7 @@ public static void ViAppendLine(ConsoleKeyInfo? key = null, object arg = null) } _singleton.SaveEditItem(EditItemInsertChar.Create('\n', insertPoint)); _singleton.Render(); - ViInsertWithAppend(); + ViInsertWithAppendImpl(); } private void MoveToEndOfPhrase() diff --git a/PSReadLine/Render.Helper.cs b/PSReadLine/Render.Helper.cs index 9fd67159d..8fc56bea1 100644 --- a/PSReadLine/Render.Helper.cs +++ b/PSReadLine/Render.Helper.cs @@ -3,6 +3,7 @@ --********************************************************************/ using System; +using System.Text; namespace Microsoft.PowerShell { @@ -70,6 +71,26 @@ internal static int LengthInBufferCells(string str, int start, int end) return sum; } + internal static int LengthInBufferCells(StringBuilder sb, int start, int end) + { + var sum = 0; + for (var i = start; i < end; i++) + { + var c = sb[i]; + if (c == 0x1b && (i + 1) < end && sb[i + 1] == '[') + { + // Simple escape sequence skipping + i += 2; + while (i < end && sb[i] != 'm') + i++; + + continue; + } + sum += LengthInBufferCells(c); + } + return sum; + } + internal static int LengthInBufferCells(char c) { if (c < 256) @@ -93,7 +114,7 @@ internal static int LengthInBufferCells(char c) (c >= 0xff00 && c <= 0xff60) || /* Fullwidth Forms */ (c >= 0xffe0 && c <= 0xffe6)); // We can ignore these ranges because .Net strings use surrogate pairs - // for this range and we do not handle surrogage pairs. + // for this range and we do not handle surrogate pairs. // (c >= 0x20000 && c <= 0x2fffd) || // (c >= 0x30000 && c <= 0x3fffd) return 1 + (isWide ? 1 : 0); @@ -169,5 +190,30 @@ private static int SubstringLengthByCellsFromEnd(string text, int start, int cou return charLength; } + + private static (int newStart, int newEnd) TrimSubstringInPlace(string text, int start, int end) + { + int newStart = start; + int newEnd = end; + + for (; newStart <= end; newStart++) + { + if (!char.IsWhiteSpace(text[newStart])) + { + break; + } + } + + for (; newEnd > newStart; newEnd--) + { + if (!char.IsWhiteSpace(text[newEnd])) + { + break; + } + } + + // Return the new start/end after triming, or (-1, -1) if the substring only consists of whitespaces. + return newStart > newEnd ? (-1, -1) : (newStart, newEnd); + } } } diff --git a/PSReadLine/Render.cs b/PSReadLine/Render.cs index 94df96316..253f7fbef 100644 --- a/PSReadLine/Render.cs +++ b/PSReadLine/Render.cs @@ -122,6 +122,14 @@ public void UpdateConsoleInfo(IConsole console) cursorLeft = console.CursorLeft; cursorTop = console.CursorTop; } + + public void UpdateConsoleInfo(int bWidth, int bHeight, int cLeft, int cTop) + { + bufferWidth = bWidth; + bufferHeight = bHeight; + cursorLeft = cLeft; + cursorTop = cTop; + } } internal readonly struct RenderDataOffset @@ -210,22 +218,197 @@ private void RenderWithPredictionQueryPaused() Render(); } - private void Render() + private void Render(bool force = false) { - // If there are a bunch of keys queued up, skip rendering if we've rendered - // recently. - if (_queuedKeys.Count > 10 && (_lastRenderTime.ElapsedMilliseconds < 50)) - { - // We won't render, but most likely the tokens will be different, so make - // sure we don't use old tokens, also allow garbage to get collected. - _tokens = null; - _ast = null; - _parseErrors = null; - _waitingToRender = true; - return; + if (!force) + { + // If there are a bunch of keys queued up, skip rendering if we've rendered very recently. + long elapsedMs = _lastRenderTime.ElapsedMilliseconds; + if (_queuedKeys.Count > 10 && elapsedMs < 50) + { + // We won't render, but most likely the tokens will be different, so make + // sure we don't use old tokens, also allow garbage to get collected. + _tokens = null; + _ast = null; + _parseErrors = null; + _waitingToRender = true; + return; + } + + // If we've rendered very recently, skip the terminal window resizing check as it's unlikely + // to happen in such a short time interval. + // We try to avoid unnecessary resizing check because it requires getting the cursor position + // which would force a network round trip in an environment where front-end xtermjs talking to + // a server-side PTY via websocket. Without querying for cursor position, content written on + // the server side could be buffered, which is much more performant. + // See the following 2 GitHub issues for more context: + // - https://github.com/PowerShell/PSReadLine/issues/3879#issuecomment-2573996070 + // - https://github.com/PowerShell/PowerShell/issues/24696 + if (elapsedMs < 50) + { + _handlePotentialResizing = false; + } + } + + // Use simplified rendering for screen readers + if (Options.ScreenReaderModeEnabled) + { + RenderForScreenReader(); + } + else + { + ForceRender(); + } + } + + private void RenderForScreenReader() + { + int bufferWidth = _console.BufferWidth; + int bufferHeight = _console.BufferHeight; + + static int FindCommonPrefixLength(string leftStr, string rightStr) + { + if (string.IsNullOrEmpty(leftStr) || string.IsNullOrEmpty(rightStr)) + { + return 0; + } + + int i = 0; + int minLength = Math.Min(leftStr.Length, rightStr.Length); + + while (i < minLength && leftStr[i] == rightStr[i]) + { + i++; + } + + return i; + } + + // For screen readers, we are just comparing the previous and current buffer text + // (without colors) and only writing the differences. + // + // Note that we don't call QueryForSuggestion() which is the only + // entry into the prediction logic, so while it could be enabled, it + // won't do anything in this rendering implementation. + string parsedInput = ParseInput(); + StringBuilder buffer = new(parsedInput); + + // Really simple handling of a status line: append it! + if (!string.IsNullOrEmpty(_statusLinePrompt)) + { + buffer.Append("\n"); + buffer.Append(_statusLinePrompt); + buffer.Append(_statusBuffer); + } + + string currentBuffer = buffer.ToString(); + string previousBuffer = _previousRender.lines[0].Line; + + // In case the buffer was resized. + RecomputeInitialCoords(isTextBufferUnchanged: false); + + // Make cursor invisible while we're rendering. + _console.CursorVisible = false; + + if (currentBuffer == previousBuffer) + { + // No-op, such as when selecting text or otherwise re-entering. + } + else if (previousBuffer.Length == 0) + { + // Previous buffer was empty so we just render the current buffer, + // and we don't need to move the cursor. + _console.Write(currentBuffer); + } + else + { + // Calculate what to render and where to start the rendering. + int commonPrefixLength = FindCommonPrefixLength(previousBuffer, currentBuffer); + + // If we're scrolling through history we always want to re-render. + // Writing only the diff in this scenario is a weird UX. + if (commonPrefixLength > 0 && _anyHistoryCommandCount == 0) + { + // We need to differentially render, possibly with a partial rewrite. + if (commonPrefixLength != previousBuffer.Length) + { + // The buffers share a common prefix but the previous buffer has additional content. + // Move cursor to where the difference starts and clear so we can rewrite. + var diffPoint = ConvertOffsetToPoint(commonPrefixLength, buffer); + _console.SetCursorPosition(diffPoint.X, diffPoint.Y); + _console.Write("\x1b[0J"); + } // Otherwise the previous buffer is a complete prefix and we just write. + + // TODO: There is a rare edge case where the common prefix can be incorrectly + // calculated because the incoming replacement text matches the text at the current + // cursor position. Unfortunately we don't have a solution yet. For example: + // + // 1. Previous line is "abcdef" and cursor is at (before) the letter "d" + // 2. Paste "defghi" so currentBuffer is "abcdefghidef" + // 3. The diff is "ghidef" and because commonPrefixLength == previousBuffer.Length + // 4. The terminal will incorrectly display "abcghidef" instead of "abcdefghidef" + + // Finally, write the diff. + var diffData = currentBuffer.Substring(commonPrefixLength); + _console.Write(diffData); + } + else + { + // The buffers are completely different so we need to rewrite from the start. + _console.SetCursorPosition(_initialX, _initialY); + _console.Write("\x1b[0J"); + _console.Write(currentBuffer); + } + } + + // If we had to wrap to render everything, update _initialY + var endPoint = ConvertOffsetToPoint(currentBuffer.Length, buffer); + if (endPoint.Y >= bufferHeight) + { + // We had to scroll to render everything, update _initialY. + int offset = 1; // Base case to handle zero-indexing. + if (endPoint.X == 0 && !currentBuffer.EndsWith("\n")) + { + // The line hasn't actually wrapped yet because we have exactly filled the line. + offset -= 1; + } + int scrolledLines = endPoint.Y - bufferHeight + offset; + _initialY -= scrolledLines; + } + + // Calculate the coord to place the cursor for the next input. + var point = ConvertOffsetToPoint(_current, buffer); + + if (point.Y == bufferHeight) + { + // The cursor top exceeds the buffer height and it hasn't already wrapped, + // (because we have exactly filled the line) so we need to scroll up the buffer by 1 line. + if (point.X == 0) + { + _console.Write("\n"); + } + + // Adjust the initial cursor position and the to-be-set cursor position + // after scrolling up the buffer. + _initialY -= 1; + point.Y -= 1; } - ForceRender(); + _console.SetCursorPosition(point.X, point.Y); + _console.CursorVisible = true; + + // Preserve the current render data. + var renderData = new RenderData + { + lines = new RenderedLineData[] { new(currentBuffer, isFirstLogicalLine: true) }, + errorPrompt = (_parseErrors != null && _parseErrors.Length > 0) // Not yet used. + }; + _previousRender = renderData; + _previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y); + _previousRender.initialY = _initialY; + + _lastRenderTime.Restart(); + _waitingToRender = false; } private void ForceRender() @@ -239,7 +422,7 @@ private void ForceRender() // and minimize writing more than necessary on the next render.) var renderLines = new RenderedLineData[logicalLineCount]; - var renderData = new RenderData {lines = renderLines}; + var renderData = new RenderData { lines = renderLines }; for (var i = 0; i < logicalLineCount; i++) { var line = _consoleBufferLines[i].ToString(); @@ -294,11 +477,7 @@ void RenderOneChar(char charToRender, bool toEmphasize) _consoleBufferLines[currentLogicalLine].Append(VTColorUtils.AnsiReset); } - currentLogicalLine += 1; - if (currentLogicalLine == _consoleBufferLines.Count) - { - _consoleBufferLines.Add(new StringBuilder(COMMON_WIDEST_CONSOLE_WIDTH)); - } + NextBufferLine(_consoleBufferLines, ref currentLogicalLine); // Reset the color for continuation prompt so the color sequence will always be explicitly // specified for continuation prompt in the generated render strings. @@ -458,11 +637,7 @@ void RenderOneChar(char charToRender, bool toEmphasize) if (_statusLinePrompt != null) { - currentLogicalLine += 1; - if (currentLogicalLine > _consoleBufferLines.Count - 1) - { - _consoleBufferLines.Add(new StringBuilder(COMMON_WIDEST_CONSOLE_WIDTH)); - } + NextBufferLine(_consoleBufferLines, ref currentLogicalLine); color = _statusIsErrorMessage ? Options._errorColor : defaultColor; UpdateColorsIfNecessary(color); @@ -478,6 +653,20 @@ void RenderOneChar(char charToRender, bool toEmphasize) return currentLogicalLine + 1; } + /// + /// Return the next logical line buffer, and create a new one if we are at the end. + /// + private static StringBuilder NextBufferLine(List consoleBufferLines, ref int current) + { + current += 1; + if (current == consoleBufferLines.Count) + { + consoleBufferLines.Add(new StringBuilder(COMMON_WIDEST_CONSOLE_WIDTH)); + } + + return consoleBufferLines[current]; + } + /// /// Flip the color on the prompt if the error state changed. /// @@ -844,9 +1033,6 @@ void UpdateColorsIfNecessary(string newColor) WriteBlankLines(lineCount); } - // Preserve the current render data. - _previousRender = renderData; - // If we counted pseudo physical lines, deduct them to get the real physical line counts // before updating '_initialY'. physicalLine -= pseudoPhysicalLineOffset; @@ -922,7 +1108,9 @@ void UpdateColorsIfNecessary(string newColor) _console.SetCursorPosition(point.X, point.Y); _console.CursorVisible = true; - _previousRender.UpdateConsoleInfo(_console); + // Preserve the current render data. + _previousRender = renderData; + _previousRender.UpdateConsoleInfo(bufferWidth, bufferHeight, point.X, point.Y); _previousRender.initialY = _initialY; // TODO: set WindowTop if necessary @@ -1162,23 +1350,34 @@ private void MoveCursor(int newCursor) _previousRender.initialY = _initialY; } - // While waiting to render, and a keybinding has occured that is moving the cursor, + // While waiting to render, and a keybinding has occurred that is moving the cursor, // converting offset to point could potentially result in an invalid screen position, // but the insertion point should reflect the move. _current = newCursor; } - internal Point ConvertOffsetToPoint(int offset) + internal Point EndOfBufferPosition() + { + return ConvertOffsetToPoint(_buffer.Length); + } + + internal Point ConvertOffsetToPoint(int offset, StringBuilder buffer = null) { + // This lets us re-use the logic in the screen reader rendering implementation + // where the status line is added to the buffer without modifying the local state. + buffer ??= _buffer; + int x = _initialX; int y = _initialY; int bufferWidth = _console.BufferWidth; - var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt); + var continuationPromptLength = Options.ScreenReaderModeEnabled + ? 0 + : LengthInBufferCells(Options.ContinuationPrompt); for (int i = 0; i < offset; i++) { - char c = _buffer[i]; + char c = buffer[i]; if (c == '\n') { y += 1; @@ -1196,7 +1395,7 @@ internal Point ConvertOffsetToPoint(int offset) // If cursor is at column 0 and the next character is newline, let the next loop // iteration increment y. - if (x != 0 || !(i + 1 < offset && _buffer[i + 1] == '\n')) + if (x != 0 || !(i + 1 < offset && buffer[i + 1] == '\n')) { y += 1; } @@ -1205,9 +1404,9 @@ internal Point ConvertOffsetToPoint(int offset) } // If next character actually exists, and isn't newline, check if wider than the space left on the current line. - if (_buffer.Length > offset && _buffer[offset] != '\n') + if (buffer.Length > offset && buffer[offset] != '\n') { - int size = LengthInBufferCells(_buffer[offset]); + int size = LengthInBufferCells(buffer[offset]); if (x + size > bufferWidth) { // Character was wider than remaining space, so character, and cursor, appear on next line. @@ -1226,7 +1425,10 @@ private int ConvertLineAndColumnToOffset(Point point) int y = _initialY; int bufferWidth = _console.BufferWidth; - var continuationPromptLength = LengthInBufferCells(Options.ContinuationPrompt); + var continuationPromptLength = Options.ScreenReaderModeEnabled + ? 0 + : LengthInBufferCells(Options.ContinuationPrompt); + for (offset = 0; offset < _buffer.Length; offset++) { // If we are on the correct line, return when we find @@ -1670,6 +1872,12 @@ private bool PromptYesOrNo(string s) public static void ScrollDisplayUp(ConsoleKeyInfo? key = null, object arg = null) { TryGetArgAsInt(arg, out var numericArg, +1); + + if (UpdateListByPaging(pageUp: true, numericArg)) + { + return; + } + var console = _singleton._console; var newTop = console.WindowTop - (numericArg * console.WindowHeight); if (newTop < 0) @@ -1685,6 +1893,12 @@ public static void ScrollDisplayUp(ConsoleKeyInfo? key = null, object arg = null public static void ScrollDisplayUpLine(ConsoleKeyInfo? key = null, object arg = null) { TryGetArgAsInt(arg, out var numericArg, +1); + + if (UpdateListByLoopingSources(jumpUp: true, numericArg)) + { + return; + } + var console = _singleton._console; var newTop = console.WindowTop - numericArg; if (newTop < 0) @@ -1700,6 +1914,12 @@ public static void ScrollDisplayUpLine(ConsoleKeyInfo? key = null, object arg = public static void ScrollDisplayDown(ConsoleKeyInfo? key = null, object arg = null) { TryGetArgAsInt(arg, out var numericArg, +1); + + if (UpdateListByPaging(pageUp: false, numericArg)) + { + return; + } + var console = _singleton._console; var newTop = console.WindowTop + (numericArg * console.WindowHeight); if (newTop > (console.BufferHeight - console.WindowHeight)) @@ -1715,6 +1935,12 @@ public static void ScrollDisplayDown(ConsoleKeyInfo? key = null, object arg = nu public static void ScrollDisplayDownLine(ConsoleKeyInfo? key = null, object arg = null) { TryGetArgAsInt(arg, out var numericArg, +1); + + if (UpdateListByLoopingSources(jumpUp: false, numericArg)) + { + return; + } + var console = _singleton._console; var newTop = console.WindowTop + numericArg; if (newTop > (console.BufferHeight - console.WindowHeight)) @@ -1738,7 +1964,7 @@ public static void ScrollDisplayTop(ConsoleKeyInfo? key = null, object arg = nul public static void ScrollDisplayToCursor(ConsoleKeyInfo? key = null, object arg = null) { // Ideally, we'll put the last input line at the bottom of the window - var point = _singleton.ConvertOffsetToPoint(_singleton._buffer.Length); + var point = _singleton.EndOfBufferPosition(); var console = _singleton._console; var newTop = point.Y - console.WindowHeight + 1; diff --git a/PSReadLine/Replace.vi.cs b/PSReadLine/Replace.vi.cs index efd23e469..8617f79c0 100644 --- a/PSReadLine/Replace.vi.cs +++ b/PSReadLine/Replace.vi.cs @@ -161,7 +161,7 @@ private static void ViReplaceWord(ConsoleKeyInfo? key, object arg) && !_singleton.IsDelimiter(_singleton._lastWordDelimiter, _singleton.Options.WordDelimiters) && _singleton._shouldAppend) { - ViInsertWithAppend(key, arg); + ViInsertWithAppendImpl(key, arg); } else { @@ -180,7 +180,7 @@ private static void ViReplaceGlob(ConsoleKeyInfo? key, object arg) } if (_singleton._current == _singleton._buffer.Length - 1) { - ViInsertWithAppend(key, arg); + ViInsertWithAppendImpl(key, arg); } else { @@ -194,7 +194,7 @@ private static void ViReplaceEndOfWord(ConsoleKeyInfo? key, object arg) DeleteEndOfWord(key, arg); if (_singleton._current == _singleton._buffer.Length - 1) { - ViInsertWithAppend(key, arg); + ViInsertWithAppendImpl(key, arg); } else { @@ -208,7 +208,7 @@ private static void ViReplaceEndOfGlob(ConsoleKeyInfo? key, object arg) ViDeleteEndOfGlob(key, arg); if (_singleton._current == _singleton._buffer.Length - 1) { - ViInsertWithAppend(key, arg); + ViInsertWithAppendImpl(key, arg); } else { @@ -270,13 +270,17 @@ private static void ViReplaceToChar(char keyChar, ConsoleKeyInfo? key = null, ob { if (_singleton._current < initialCurrent || _singleton._current >= _singleton._buffer.Length) { - ViInsertWithAppend(key, arg); + ViInsertWithAppendImpl(key, arg); } else { ViInsertMode(key, arg); } } + else + { + _singleton._groupUndoHelper.EndGroup(); + } } /// @@ -295,6 +299,10 @@ private static void ViReplaceToCharBack(char keyChar, ConsoleKeyInfo? key = null { ViInsertMode(key, arg); } + else + { + _singleton._groupUndoHelper.EndGroup(); + } } /// @@ -314,6 +322,10 @@ private static void ViReplaceToBeforeChar(char keyChar, ConsoleKeyInfo? key = nu { ViInsertMode(key, arg); } + else + { + _singleton._groupUndoHelper.EndGroup(); + } } /// @@ -332,8 +344,10 @@ private static void ViReplaceToBeforeCharBack(char keyChar, ConsoleKeyInfo? key { ViInsertMode(key, arg); } + else + { + _singleton._groupUndoHelper.EndGroup(); + } } - - } } diff --git a/PSReadLine/SamplePSReadLineProfile.ps1 b/PSReadLine/SamplePSReadLineProfile.ps1 index 8ccb8c1ee..6da7e8617 100644 --- a/PSReadLine/SamplePSReadLineProfile.ps1 +++ b/PSReadLine/SamplePSReadLineProfile.ps1 @@ -24,6 +24,9 @@ Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward # typed text is used as the substring pattern for filtering. A selected command # is inserted to the command line without invoking. Multiple command selection # is supported, e.g. selected by Ctrl + Click. +# As another example, the module 'F7History' does something similar but uses the +# console GUI instead of Out-GridView. Details about this module can be found at +# PowerShell Gallery: https://www.powershellgallery.com/packages/F7History. Set-PSReadLineKeyHandler -Key F7 ` -BriefDescription History ` -LongDescription 'Show command history' ` @@ -519,7 +522,7 @@ Set-PSReadLineKeyHandler -Key F1 ` # # Ctrl+Shift+j then type a key to mark the current directory. -# Ctrj+j then the same key will change back to that directory without +# Ctrl+j then the same key will change back to that directory without # needing to type cd and won't change the command line. # @@ -603,7 +606,7 @@ Set-PSReadLineKeyHandler -Key RightArrow ` # Cycle through arguments on current line and select the text. This makes it easier to quickly change the argument if re-running a previously run command from the history # or if using a psreadline predictor. You can also use a digit argument to specify which argument you want to select, i.e. Alt+1, Alt+a selects the first argument -# on the command line. +# on the command line. Set-PSReadLineKeyHandler -Key Alt+a ` -BriefDescription SelectCommandArguments ` -LongDescription "Set current selection to next command argument in the command line. Use of digit argument selects argument by position" ` @@ -656,3 +659,36 @@ Set-PSReadLineKeyHandler -Key Alt+a ` [Microsoft.PowerShell.PSConsoleReadLine]::SetMark($null, $null) [Microsoft.PowerShell.PSConsoleReadLine]::SelectForwardChar($null, ($nextAst.Extent.EndOffset - $nextAst.Extent.StartOffset) - $endOffsetAdjustment) } + +# Allow you to type a Unicode code point, then pressing `Alt+x` to transform it into a Unicode char. +Set-PSReadLineKeyHandler -Chord 'Alt+x' ` + -BriefDescription ToUnicodeChar ` + -LongDescription "Transform Unicode code point into a UTF-16 encoded string" ` + -ScriptBlock { + $buffer = $null + $cursor = 0 + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref] $buffer, [ref] $cursor) + if ($cursor -lt 4) { + return + } + + $number = 0 + $isNumber = [int]::TryParse( + $buffer.Substring($cursor - 4, 4), + [System.Globalization.NumberStyles]::AllowHexSpecifier, + $null, + [ref] $number) + + if (-not $isNumber) { + return + } + + try { + $unicode = [char]::ConvertFromUtf32($number) + } catch { + return + } + + [Microsoft.PowerShell.PSConsoleReadLine]::Delete($cursor - 4, 4) + [Microsoft.PowerShell.PSConsoleReadLine]::Insert($unicode) +} diff --git a/PSReadLine/StringBuilderCharacterExtensions.cs b/PSReadLine/StringBuilderCharacterExtensions.cs new file mode 100644 index 000000000..ab3faaea3 --- /dev/null +++ b/PSReadLine/StringBuilderCharacterExtensions.cs @@ -0,0 +1,78 @@ +ο»Ώusing System.Text; + +namespace Microsoft.PowerShell +{ + internal static class StringBuilderCharacterExtensions + { + /// + /// Returns true if the character at the specified position is a visible whitespace character. + /// A blank character is defined as a SPACE or a TAB. + /// + /// + /// + /// + public static bool IsVisibleBlank(this StringBuilder buffer, int i) + { + var c = buffer[i]; + + // [:blank:] of vim's pattern matching behavior + // defines blanks as SPACE and TAB characters. + + return c == ' ' || c == '\t'; + } + + /// + /// Returns true if the character at the specified position is + /// not present in a list of word-delimiter characters. + /// + /// + /// + /// + /// + public static bool InWord(this StringBuilder buffer, int i, string wordDelimiters) + { + return Character.IsInWord(buffer[i], wordDelimiters); + } + + /// + /// Returns true if the character at the specified position is + /// at the end of the buffer + /// + /// + /// + /// + public static bool IsAtEndOfBuffer(this StringBuilder buffer, int i) + { + return i >= (buffer.Length - 1); + } + + /// + /// Returns true if the character at the specified position is + /// a unicode whitespace character. + /// + /// + /// + /// + public static bool IsWhiteSpace(this StringBuilder buffer, int i) + { + // Treat just beyond the end of buffer as whitespace because + // it looks like whitespace to the user even though they haven't + // entered a character yet. + return i >= buffer.Length || char.IsWhiteSpace(buffer[i]); + } + } + + public static class Character + { + /// + /// Returns true if the character not present in a list of word-delimiter characters. + /// + /// + /// + /// + public static bool IsInWord(char c, string wordDelimiters) + { + return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0; + } + } +} diff --git a/PSReadLine/StringBuilderExtensions.cs b/PSReadLine/StringBuilderLinewiseExtensions.cs similarity index 72% rename from PSReadLine/StringBuilderExtensions.cs rename to PSReadLine/StringBuilderLinewiseExtensions.cs index 08deef333..40320a97d 100644 --- a/PSReadLine/StringBuilderExtensions.cs +++ b/PSReadLine/StringBuilderLinewiseExtensions.cs @@ -72,6 +72,26 @@ internal static Range GetRange(this StringBuilder buffer, int lineIndex, int lin endPosition - startPosition + 1 ); } + + /// + /// Returns true if the specified position is on an empty logical line. + /// + /// + /// + /// + public static bool IsLogigalLineEmpty(this StringBuilder buffer, int cursor) + { + // the cursor is on a logical line considered empty if... + return + // the entire buffer is empty (by definition), + buffer.Length == 0 || + // or the cursor sits at the start of the empty last line, + // meaning that it is past the end of the buffer and the + // last character in the buffer is a newline character, + (cursor == buffer.Length && buffer[cursor - 1] == '\n') || + // or if the cursor is on a newline character. + (cursor > 0 && buffer[cursor] == '\n'); + } } internal static class StringBuilderPredictionExtensions diff --git a/PSReadLine/StringBuilderTextObjectExtensions.cs b/PSReadLine/StringBuilderTextObjectExtensions.cs new file mode 100644 index 000000000..421ab3454 --- /dev/null +++ b/PSReadLine/StringBuilderTextObjectExtensions.cs @@ -0,0 +1,113 @@ +ο»Ώusing System; +using System.Text; + +namespace Microsoft.PowerShell +{ + internal static class StringBuilderTextObjectExtensions + { + private const string WhiteSpace = " \n\t"; + + /// + /// Returns the position of the beginning of the current word as delimited by white space and delimiters + /// This method differs from : + /// - When the cursor location is on the first character of a word, + /// returns the position of the previous word, whereas this method returns the cursor location. + /// - When the cursor location is in a word, both methods return the same result. + /// This method supports VI "iw" text object. + /// + public static int ViFindBeginningOfWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) + { + // Cursor may be past the end of the buffer when calling this method + // this may happen if the cursor is at the beginning of a new line. + var i = Math.Min(position, buffer.Length - 1); + + // If starting on a word consider a text object as a sequence of characters excluding the delimiters, + // otherwise, consider a word as a sequence of delimiters. + var delimiters = wordDelimiters; + var isInWord = buffer.InWord(i, wordDelimiters); + + if (isInWord) + { + // For the purpose of this method, whitespace character is considered a delimiter. + delimiters += WhiteSpace; + } + else + { + char c = buffer[i]; + if ((wordDelimiters + '\n').IndexOf(c) == -1 && char.IsWhiteSpace(c)) + { + // Current position points to a whitespace that is not a newline. + delimiters = WhiteSpace; + } + else + { + delimiters += '\n'; + } + } + + var isTextObjectChar = isInWord + ? (Func)(c => delimiters.IndexOf(c) == -1) + : c => delimiters.IndexOf(c) != -1; + + var beginning = i; + while (i >= 0 && isTextObjectChar(buffer[i])) + { + beginning = i--; + } + + return beginning; + } + + /// + /// Finds the position of the beginning of the next word object starting from the specified position. + /// If positioned on the last word in the buffer, returns buffer length + 1. + /// This method supports VI "iw" text-object. + /// iw: "inner word", select words. White space between words is counted too. + /// + public static int ViFindBeginningOfNextWordObjectBoundary(this StringBuilder buffer, int position, string wordDelimiters) + { + // Cursor may be past the end of the buffer when calling this method + // this may happen if the cursor is at the beginning of a new line. + var i = Math.Min(position, buffer.Length - 1); + + // Always skip the first newline character. + if (buffer[i] == '\n' && i < buffer.Length - 1) + { + ++i; + } + + // If starting on a word consider a text object as a sequence of characters excluding the delimiters, + // otherwise, consider a word as a sequence of delimiters. + var delimiters = wordDelimiters; + var isInWord = buffer.InWord(i, wordDelimiters); + + if (isInWord) + { + delimiters += WhiteSpace; + } + else if (char.IsWhiteSpace(buffer[i])) + { + delimiters = " \t"; + } + + var isTextObjectChar = isInWord + ? (Func)(c => delimiters.IndexOf(c) == -1) + : c => delimiters.IndexOf(c) != -1; + + // Try to skip a second newline characters to replicate vim behaviour. + if (buffer[i] == '\n' && i < buffer.Length - 1) + { + ++i; + } + + // Skip to next non-word characters. + while (i < buffer.Length && isTextObjectChar(buffer[i])) + { + ++i; + } + + // Make sure end includes the starting position. + return Math.Max(i, position); + } + } +} diff --git a/PSReadLine/TextObjects.Vi.cs b/PSReadLine/TextObjects.Vi.cs new file mode 100644 index 000000000..ea9810fbc --- /dev/null +++ b/PSReadLine/TextObjects.Vi.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; + +namespace Microsoft.PowerShell +{ + public partial class PSConsoleReadLine + { + internal enum TextObjectOperation + { + None, + Change, + Delete, + } + + internal enum TextObjectSpan + { + None, + Around, + Inner, + } + + private TextObjectOperation _textObjectOperation = TextObjectOperation.None; + private TextObjectSpan _textObjectSpan = TextObjectSpan.None; + + private readonly Dictionary> _textObjectHandlers = new() + { + [TextObjectOperation.Delete] = new() { [TextObjectSpan.Inner] = MakeKeyHandler(ViDeleteInnerWord, "ViDeleteInnerWord") }, + }; + + private void ViChordDeleteTextObject(ConsoleKeyInfo? key = null, object arg = null) + { + _textObjectOperation = TextObjectOperation.Delete; + ViChordTextObject(key, arg); + } + + private void ViChordTextObject(ConsoleKeyInfo? key = null, object arg = null) + { + if (!key.HasValue) + { + ResetTextObjectState(); + throw new ArgumentNullException(nameof(key)); + } + + _textObjectSpan = GetRequestedTextObjectSpan(key.Value); + + // Handle text object + var textObjectKey = ReadKey(); + if (_viChordTextObjectsTable.TryGetValue(textObjectKey, out _)) + { + _singleton.ProcessOneKey(textObjectKey, _viChordTextObjectsTable, ignoreIfNoAction: true, arg: arg); + } + else + { + ResetTextObjectState(); + Ding(); + } + } + + private TextObjectSpan GetRequestedTextObjectSpan(ConsoleKeyInfo key) + { + if (key.KeyChar == 'i') + { + return TextObjectSpan.Inner; + } + else if (key.KeyChar == 'a') + { + return TextObjectSpan.Around; + } + else + { + System.Diagnostics.Debug.Assert(false); + throw new NotSupportedException(); + } + } + + private static void ViHandleTextObject(ConsoleKeyInfo? key = null, object arg = null) + { + if (!_singleton._textObjectHandlers.TryGetValue(_singleton._textObjectOperation, out var textObjectHandler) || + !textObjectHandler.TryGetValue(_singleton._textObjectSpan, out var handler)) + { + ResetTextObjectState(); + Ding(); + return; + } + + handler.Action(key, arg); + } + + private static void ResetTextObjectState() + { + _singleton._textObjectOperation = TextObjectOperation.None; + _singleton._textObjectSpan = TextObjectSpan.None; + } + + private static void ViDeleteInnerWord(ConsoleKeyInfo? key = null, object arg = null) + { + var delimiters = _singleton.Options.WordDelimiters; + + if (!TryGetArgAsInt(arg, out var numericArg, 1)) + { + return; + } + + if (_singleton._buffer.Length == 0) + { + if (numericArg > 1) + { + Ding(); + } + return; + } + + // Unless at the end of the buffer a single delete word should not delete backwards + // so if the cursor is on an empty line, do nothing. + if (numericArg == 1 && + _singleton._current < _singleton._buffer.Length && + _singleton._buffer.IsLogigalLineEmpty(_singleton._current)) + { + return; + } + + var start = _singleton._buffer.ViFindBeginningOfWordObjectBoundary(_singleton._current, delimiters); + var end = _singleton._current; + + // Attempting to find a valid position for multiple words. + // If no valid position is found, this is a no-op + { + while (numericArg-- > 0 && end < _singleton._buffer.Length) + { + end = _singleton._buffer.ViFindBeginningOfNextWordObjectBoundary(end, delimiters); + } + + // Attempting to delete too many words should ding. + if (numericArg > 0) + { + Ding(); + return; + } + } + + if (end > 0 && _singleton._buffer.IsAtEndOfBuffer(end - 1) && _singleton._buffer.InWord(end - 1, delimiters)) + { + _singleton._shouldAppend = true; + } + + _singleton.RemoveTextToViRegister(start, end - start); + _singleton.AdjustCursorPosition(start); + _singleton.Render(); + } + + /// + /// Attempt to set the cursor at the specified position. + /// + /// + /// + private int AdjustCursorPosition(int position) + { + // This method might prove useful in a more general case. + if (_buffer.Length == 0) + { + _current = 0; + return 0; + } + + var maxPosition = _buffer[_buffer.Length - 1] == '\n' + ? _buffer.Length + : _buffer.Length - 1; + + var newCurrent = Math.Min(position, maxPosition); + var beginning = GetBeginningOfLinePos(newCurrent); + + if (newCurrent < _buffer.Length && _buffer[newCurrent] == '\n' && (newCurrent + ViEndOfLineFactor > beginning)) + { + newCurrent += ViEndOfLineFactor; + } + + _current = newCurrent; + return newCurrent; + } + } +} diff --git a/PSReadLine/Words.cs b/PSReadLine/Words.cs index 5c4c09f67..7bdc34a88 100644 --- a/PSReadLine/Words.cs +++ b/PSReadLine/Words.cs @@ -90,13 +90,7 @@ private Token FindToken(int current, FindTokenMode mode) private bool InWord(int index, string wordDelimiters) { - char c = _buffer[index]; - return InWord(c, wordDelimiters); - } - - private bool InWord(char c, string wordDelimiters) - { - return !char.IsWhiteSpace(c) && wordDelimiters.IndexOf(c) < 0; + return _buffer.InWord(index, wordDelimiters); } /// diff --git a/PSReadLine/Words.vi.cs b/PSReadLine/Words.vi.cs index 8ba987bae..5a475c19f 100644 --- a/PSReadLine/Words.vi.cs +++ b/PSReadLine/Words.vi.cs @@ -2,6 +2,8 @@ Copyright (c) Microsoft Corporation. All rights reserved. --********************************************************************/ +using System; + namespace Microsoft.PowerShell { public partial class PSConsoleReadLine @@ -106,10 +108,7 @@ private int ViFindNextWordFromWord(int i, string wordDelimiters) /// private bool IsWhiteSpace(int i) { - // Treat just beyond the end of buffer as whitespace because - // it looks like whitespace to the user even though they haven't - // entered a character yet. - return i >= _buffer.Length || char.IsWhiteSpace(_buffer[i]); + return _buffer.IsWhiteSpace(i); } /// diff --git a/PSReadLine/YankPaste.vi.cs b/PSReadLine/YankPaste.vi.cs index 727fec872..d59fdd5cc 100644 --- a/PSReadLine/YankPaste.vi.cs +++ b/PSReadLine/YankPaste.vi.cs @@ -80,7 +80,7 @@ private void SaveLinesToClipboard(int lineIndex, int lineCount) /// /// /// Use 'false' as the default value because this method is used a lot by VI operations, - /// and for VI opeartions, we do NOT want to move the cursor to the end when undoing a + /// and for VI operations, we do NOT want to move the cursor to the end when undoing a /// deletion. /// private void RemoveTextToViRegister( diff --git a/Polyfill/CommandPrediction.cs b/Polyfill/CommandPrediction.cs deleted file mode 100644 index e7b331a8b..000000000 --- a/Polyfill/CommandPrediction.cs +++ /dev/null @@ -1,232 +0,0 @@ -#if LEGACY - -using System.Collections.Generic; -using System.Threading.Tasks; -using System.Management.Automation.Language; - -namespace System.Management.Automation.Subsystem.Prediction -{ - /// - /// Kinds of prediction clients. - /// - public enum PredictionClientKind - { - /// - /// A terminal client, representing the command-line experience. - /// - Terminal, - - /// - /// An editor client, representing the editor experience. - /// - Editor, - } - - /// - /// The class represents a client that interacts with predictors. - /// - public sealed class PredictionClient - { - /// - /// Gets the client name. - /// - [HiddenAttribute] - public string Name { get; } - - /// - /// Gets the client kind. - /// - [HiddenAttribute] - public PredictionClientKind Kind { get; } - - /// - /// Initializes a new instance of the class. - /// - /// Name of the interactive client. - /// Kind of the interactive client. - [HiddenAttribute] - public PredictionClient(string name, PredictionClientKind kind) - { - Name = name; - Kind = kind; - } - } - - /// - /// The class represents the prediction result from a predictor. - /// - public sealed class PredictionResult - { - /// - /// Gets the Id of the predictor. - /// - [HiddenAttribute] - public Guid Id { get; } - - /// - /// Gets the name of the predictor. - /// - [HiddenAttribute] - public string Name { get; } - - /// - /// Gets the mini-session id that represents a specific invocation that returns this result. - /// When it's not specified, it's considered by a client that the predictor doesn't expect feedback. - /// - [HiddenAttribute] - public uint? Session { get; } - - /// - /// Gets the suggestions. - /// - [HiddenAttribute] - public IReadOnlyList Suggestions { get; } - - internal PredictionResult(Guid id, string name, uint? session, List suggestions) - { - Id = id; - Name = name; - Session = session; - Suggestions = suggestions; - } - } - - /// - /// The class represents a predictive suggestion generated by a predictor. - /// - public sealed class PredictiveSuggestion - { - /// - /// Gets the suggestion. - /// - [HiddenAttribute] - public string SuggestionText { get; } - - /// - /// Gets the tooltip of the suggestion. - /// - [HiddenAttribute] - public string ToolTip { get; } - - /// - /// Initializes a new instance of the class. - /// - /// The predictive suggestion text. - [HiddenAttribute] - public PredictiveSuggestion(string suggestion) - : this(suggestion, toolTip: null) - { - } - - /// - /// Initializes a new instance of the class. - /// - /// The predictive suggestion text. - /// The tooltip of the suggestion. - [HiddenAttribute] - public PredictiveSuggestion(string suggestion, string toolTip) - { - if (string.IsNullOrEmpty(suggestion)) - { - throw new ArgumentNullException(nameof(suggestion)); - } - - SuggestionText = suggestion; - ToolTip = toolTip; - } - } - - /// - /// Provides a set of possible predictions for given input. - /// - public static class CommandPrediction - { - /// - /// Collect the predictive suggestions from registered predictors using the default timeout. - /// - /// Represents the client that initiates the call. - /// The object from parsing the current command line input. - /// The objects from parsing the current command line input. - /// A list of objects. - [HiddenAttribute] - public static Task> PredictInputAsync(PredictionClient client, Ast ast, Token[] astTokens) - { - return null; - } - - /// - /// Collect the predictive suggestions from registered predictors using the specified timeout. - /// - /// Represents the client that initiates the call. - /// The object from parsing the current command line input. - /// The objects from parsing the current command line input. - /// The milliseconds to timeout. - /// A list of objects. - [HiddenAttribute] - public static Task> PredictInputAsync(PredictionClient client, Ast ast, Token[] astTokens, int millisecondsTimeout) - { - return null; - } - - /// - /// Allow registered predictors to do early processing when a command line is accepted. - /// - /// Represents the client that initiates the call. - /// History command lines provided as references for prediction. - [HiddenAttribute] - public static void OnCommandLineAccepted(PredictionClient client, IReadOnlyList history) - { - } - - /// - /// Allow registered predictors to know the execution result (success/failure) of the last accepted command line. - /// - /// Represents the client that initiates the call. - /// The last accepted command line. - /// Whether the execution of the last command line was successful. - [HiddenAttribute] - public static void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) - { - } - - /// - /// Send feedback to a predictor when one or more suggestions from it were displayed to the user. - /// - /// Represents the client that initiates the call. - /// The identifier of the predictor whose prediction result was accepted. - /// The mini-session where the displayed suggestions came from. - /// - /// When the value is > 0, it's the number of displayed suggestions from the list returned in , starting from the index 0. - /// When the value is <= 0, it means a single suggestion from the list got displayed, and the index is the absolute value. - /// - [HiddenAttribute] - public static void OnSuggestionDisplayed(PredictionClient client, Guid predictorId, uint session, int countOrIndex) - { - } - - /// - /// Send feedback to predictors about their last suggestions. - /// - /// Represents the client that initiates the call. - /// The identifier of the predictor whose prediction result was accepted. - /// The mini-session where the accepted suggestion came from. - /// The accepted suggestion text. - [HiddenAttribute] - public static void OnSuggestionAccepted(PredictionClient client, Guid predictorId, uint session, string suggestionText) - { - } - } -} - -#else - -using System.Management.Automation.Subsystem.Prediction; -using System.Runtime.CompilerServices; - -[assembly: TypeForwardedTo(typeof(PredictionClientKind))] -[assembly: TypeForwardedTo(typeof(PredictionClient))] -[assembly: TypeForwardedTo(typeof(PredictiveSuggestion))] -[assembly: TypeForwardedTo(typeof(PredictionResult))] -[assembly: TypeForwardedTo(typeof(CommandPrediction))] - -#endif diff --git a/Polyfill/Polyfill.csproj b/Polyfill/Polyfill.csproj deleted file mode 100644 index c6b7ee481..000000000 --- a/Polyfill/Polyfill.csproj +++ /dev/null @@ -1,22 +0,0 @@ - - - - Microsoft.PowerShell.PSReadLine.Polyfiller - 1.0.0.0 - net462;net6.0 - true - - - - - - - - - - - - $(DefineConstants);LEGACY - - - diff --git a/README.md b/README.md index a76c9b03f..d1ab3d11c 100644 --- a/README.md +++ b/README.md @@ -36,115 +36,34 @@ Some good resources about `PSReadLine`: - Ed Wilson (Scripting Guy) wrote a [series](https://devblogs.microsoft.com/scripting/tag/psreadline/) (2014-2015) on `PSReadLine`. - John Savill has a [video](https://www.youtube.com/watch?v=Q11sSltuTE0) (2021) covering installation, configuration, and tailoring `PSReadLine` to your liking. -## Installation +## Installation and Upgrading -There are multiple ways to install `PSReadLine`. +You will need the `1.6.0` or a higher version of [`PowerShellGet`](https://learn.microsoft.com/en-us/powershell/gallery/powershellget/install-powershellget) to install or upgrade to the latest prerelease version of `PSReadLine`. -### Install from PowerShellGallery (preferred) - -You will need the `1.6.0` or a higher version of [`PowerShellGet`](https://docs.microsoft.com/powershell/scripting/gallery/installing-psget) to install the latest prerelease version of `PSReadLine`. - -Windows PowerShell 5.1 ships an older version of `PowerShellGet` which doesn't support installing prerelease modules, -so Windows PowerShell users need to install the latest `PowerShellGet` (if not yet) by running the following commands from an elevated Windows PowerShell session: +PowerShell 6+ already has a higher version of `PowerShellGet` built-in. +However, Windows PowerShell 5.1 ships an older version of `PowerShellGet` which doesn't support installing prerelease modules. +So, Windows PowerShell users need to install the latest `PowerShellGet` (if not yet) by running the following commands from an elevated Windows PowerShell session: ```powershell -Install-Module -Name PowerShellGet -Force -Exit +Install-Module -Name PowerShellGet -Force; exit ``` -After installing `PowerShellGet`, you can get the latest prerelease version of `PSReadLine` by running +After installing `PowerShellGet`, you install or upgrade to the latest prerelease version of `PSReadLine` by running ```powershell -Install-Module PSReadLine -AllowPrerelease -Force +Install-Module PSReadLine -Repository PSGallery -Scope CurrentUser -AllowPrerelease -Force ``` If you only want to get the latest stable version, run: ```powershell -Install-Module PSReadLine +Install-Module PSReadLine -Repository PSGallery -Scope CurrentUser -Force ``` >[!NOTE] Prerelease versions will have newer features and bug fixes, but may also introduce new issues. -If you are using Windows PowerShell on Windows 10 or using PowerShell 6+, `PSReadLine` is already installed. -Windows PowerShell on the latest Windows 10 has version `2.0.0-beta2` of `PSReadLine`. -PowerShell 6+ versions have the newer prerelease versions of `PSReadLine`. - -### Install from GitHub (deprecated) - -With the preview release of PowerShellGet for PowerShell V3/V4, downloads from GitHub are deprecated. -We don't intend to update releases on GitHub, and may remove the release entirely from GitHub at some point. - -### Post Installation - -If you are using Windows PowerShell V5 or V5.1 versions, or using PowerShell 6+ versions, you are good to go and can skip this section. - -Otherwise, you need to edit your profile to import the module. -There are two profile files commonly used and the instructions are slightly different for each. -The file `C:\Users\[User]\Documents\WindowsPowerShell\profile.ps1` is used for all hosts (e.g. the `ISE` and `powershell.exe`). -If you already have this file, then you should add the following: - -```powershell -if ($host.Name -eq 'ConsoleHost') -{ - Import-Module PSReadLine -} -``` - -Alternatively, the file `C:\Users\[User]\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1` is for `powershell.exe` only. Using this file, you can simply add: - -```powershell -Import-Module PSReadLine -``` - -In either case, you can create the appropriate file if you don't already have one. - -## Upgrading - -When running one of the suggested commands below, be sure to exit all instances of `powershell.exe`, `pwsh.exe` or `pwsh`, -including those opened in `VSCode` terminals. - -Then, to make sure `PSReadLine` isn't loaded: -- _if you are on Windows_, run the suggested command below from `cmd.exe`, `powershell_ise.exe`, or via the `Win+R` shortcut; -- _if you are on Linux/macOS_, run the suggested command below from the default terminal (like `bash` or `zsh`). - - -If you are using the version of `PSReadLine` that ships with Windows PowerShell, -you need to run: `powershell -noprofile -command "Install-Module PSReadLine -Force -SkipPublisherCheck -AllowPrerelease"`. -Note: you will need to make sure [PowershellGet is updated](https://github.com/PowerShell/PSReadLine#install-from-powershellgallery-preferred) before running this command. - -If you are using the version of `PSReadLine` that ships with PowerShell 6+ versions, -you need to run: ` -noprofile -command "Install-Module PSReadLine -Force -SkipPublisherCheck -AllowPrerelease"`. - -If you've installed `PSReadLine` yourself from the PowerShell Gallery, -you can simply run: `powershell -noprofile -command "Update-Module PSReadLine -AllowPrerelease"` or -` -noprofile -command "Update-Module PSReadLine -AllowPrerelease"`, -depending on the version of PowerShell you are using. - -If you get an error like: - -```none -Remove-Item : Cannot remove item -C:\Users\{yourName}\Documents\WindowsPowerShell\Modules\PSReadLine\Microsoft.PowerShell.PSReadLine.dll: Access to the path -'C:\Users\{yourName}\Documents\WindowsPowerShell\Modules\PSReadLine\Microsoft.PowerShell.PSReadLine.dll' is denied. -``` - -or a warning like: - -```none -WARNING: The version '2.0.0' of module 'PSReadLine' is currently in use. Retry the operation after closing the applications. -``` - -Then you didn't kill all the processes that loaded `PSReadLine`. - ## Usage -To start using, just import the module: - -```powershell -Import-Module PSReadLine -``` - To use Emacs key bindings, you can use: ```powershell @@ -157,16 +76,19 @@ To view the current key bindings: Get-PSReadLineKeyHandler ``` -There are many configuration options, see the options to `Set-PSReadLineOption`. `PSReadLine` has help for it's cmdlets as well as an `about_PSReadLine` topic - see those topics for more detailed help. +There are many configuration options, see the options to `Set-PSReadLineOption`. +`PSReadLine` has help for its cmdlets as well as an `about_PSReadLine` topic - see those topics for more detailed help. -To set your own custom keybindings, use the cmdlet `Set-PSReadLineKeyHandler`. For example, for a better history experience, try: +To set your own custom keybindings, use the cmdlet `Set-PSReadLineKeyHandler`. +For example, for a better history experience, try: ```powershell Set-PSReadLineKeyHandler -Key UpArrow -Function HistorySearchBackward Set-PSReadLineKeyHandler -Key DownArrow -Function HistorySearchForward ``` -With these bindings, up arrow/down arrow will work like PowerShell/cmd if the current command line is blank. If you've entered some text though, it will search the history for commands that start with the currently entered text. +With these bindings, up arrow/down arrow will work like PowerShell/cmd if the current command line is blank. +If you've entered some text though, it will search the history for commands that start with the currently entered text. To enable bash style completion without using Emacs mode, you can use: @@ -177,7 +99,7 @@ Set-PSReadLineKeyHandler -Key Tab -Function Complete Here is a more interesting example of what is possible: ```powershell -Set-PSReadLineKeyHandler -Chord 'Oem7','Shift+Oem7' ` +Set-PSReadLineKeyHandler -Chord '"',"'" ` -BriefDescription SmartInsertQuote ` -LongDescription "Insert paired quotes if not already on a quote" ` -ScriptBlock { @@ -187,7 +109,7 @@ Set-PSReadLineKeyHandler -Chord 'Oem7','Shift+Oem7' ` $cursor = $null [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) - if ($line[$cursor] -eq $key.KeyChar) { + if ($line.Length -gt $cursor -and $line[$cursor] -eq $key.KeyChar) { # Just move the cursor [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition($cursor + 1) } @@ -200,7 +122,10 @@ Set-PSReadLineKeyHandler -Chord 'Oem7','Shift+Oem7' ` } ``` -In this example, when you type a single quote or double quote, there are two things that can happen. If the character following the cursor is not the quote typed, then a matched pair of quotes is inserted and the cursor is placed inside the the matched quotes. If the character following the cursor is the quote typed, the cursor is simply moved past the quote without inserting anything. If you use Resharper or another smart editor, this experience will be familiar. +In this example, when you type a single quote or double quote, there are two things that can happen. +If the character following the cursor is not the quote typed, then a matched pair of quotes is inserted and the cursor is placed inside the the matched quotes. +If the character following the cursor is the quote typed, the cursor is simply moved past the quote without inserting anything. +If you use `VSCode`, `Resharper`, or another smart editor, this experience will be familiar. Note that with the handler written this way, it correctly handles Undo - both quotes will be undone with one undo. @@ -211,10 +136,10 @@ See the public methods of `[Microsoft.PowerShell.PSConsoleReadLine]` to see what If you want to change the command line in some unimplmented way in your custom key binding, you can use the methods: ```powershell - [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState - [Microsoft.PowerShell.PSConsoleReadLine]::Insert - [Microsoft.PowerShell.PSConsoleReadLine]::Replace - [Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition +[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState +[Microsoft.PowerShell.PSConsoleReadLine]::Insert +[Microsoft.PowerShell.PSConsoleReadLine]::Replace +[Microsoft.PowerShell.PSConsoleReadLine]::SetCursorPosition ``` ## Developing and Contributing @@ -226,22 +151,22 @@ Please see the [Contribution Guide][] for how to develop and contribute. To build `PSReadLine` on Windows, Linux, or macOS, you must have the following installed: -* .NET Core SDK 2.1.802 or [a newer version](https://www.microsoft.com/net/download) +* .NET 6.0 or [a newer version](https://www.microsoft.com/net/download) * The PowerShell modules `InvokeBuild` and `platyPS` The build script `build.ps1` can be used to bootstrap, build and test the project. * Bootstrap: `./build.ps1 -Bootstrap` -* Build: - * Targeting .NET 4.6.2 (Windows only): `./build.ps1 -Configuration Debug -Framework net462` - * Targeting .NET Core: `./build.ps1 -Configuration Debug -Framework netcoreapp2.1` +* Build: `./build.ps1 -Configuration Debug` * Test: - * Targeting .NET 4.6.2 (Windows only): `./build.ps1 -Test -Configuration Debug -Framework net462` - * Targeting .NET Core: `./build.ps1 -Test -Configuration Debug -Framework netcoreapp2.1` + * Targeting .NET 4.7.2 (Windows only): `./build.ps1 -Test -Configuration Debug -Framework net472` + * Targeting .NET 6.0: `./build.ps1 -Test -Configuration Debug -Framework net6.0` After build, the produced artifacts can be found at `/bin/Debug`. -[Contribution Guide]: https://github.com/PowerShell/PSReadLine/blob/master/.github/CONTRIBUTING.md +In order to isolate your imported module to the one locally built, be sure to run +`pwsh -NonInteractive -NoProfile` to not automatically load the default PSReadLine module installed. +Then, load the locally built PSReadLine module by `Import-Module /bin/Debug/PSReadLine/PSReadLine.psd1`. ## Change Log @@ -251,13 +176,13 @@ The change log is available [here](https://github.com/PowerShell/PSReadLine/blob PSReadLine is licensed under the [2-Clause BSD License][]. -[2-Clause BSD License]: https://github.com/PowerShell/PSReadLine/blob/master/License.txt - ## Code of Conduct -This project has adopted the [Microsoft Open Source Code of Conduct][conduct-code]. -For more information see the [Code of Conduct FAQ][conduct-FAQ] or contact [opencode@microsoft.com][conduct-email] with any additional questions or comments. +Please see our [Code of Conduct](.github/CODE_OF_CONDUCT.md) before participating in this project. + +## Security Policy + +For any security issues, please see our [Security Policy](.github/SECURITY.md). -[conduct-code]: https://opensource.microsoft.com/codeofconduct/ -[conduct-FAQ]: https://opensource.microsoft.com/codeofconduct/faq/ -[conduct-email]: mailto:opencode@microsoft.com +[Contribution Guide]: https://github.com/PowerShell/PSReadLine/blob/master/.github/CONTRIBUTING.md +[2-Clause BSD License]: https://github.com/PowerShell/PSReadLine/blob/master/License.txt diff --git a/appveyor.yml b/appveyor.yml index 1b0df3017..84ed4dce3 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,10 +1,9 @@ -image: Visual Studio 2017 +image: Visual Studio 2022 environment: POWERSHELL_TELEMETRY_OPTOUT: 1 - # Avoid expensive initialization of dotnet cli, see: http://donovanbrown.com/post/Stop-wasting-time-during-NET-Core-builds - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: 1 PSREADLINE_TESTRUN: 1 cache: @@ -12,15 +11,15 @@ cache: install: - pwsh: | - Write-Host "PS Version: $($($PSVersionTable.PSVersion))" + Write-Host "PS Version: $($PSVersionTable.PSVersion)" ./build.ps1 -Bootstrap build_script: - pwsh: | - ./build.ps1 -Configuration Release -Framework net462 + ./build.ps1 -Configuration Release test_script: - - pwsh: ./build.ps1 -Test -Configuration Release -Framework net462 + - pwsh: ./build.ps1 -Test -Configuration Release artifacts: - path: .\bin\Release\PSReadLine.zip diff --git a/build.ps1 b/build.ps1 index 0c303bc99..8871b2179 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,20 +1,24 @@ +#Requires -Version 7.4 + <# .SYNOPSIS A script that provides simple entry points for bootstrapping, building and testing. .DESCRIPTION A script to make it easy to bootstrap, build and run tests. + This build targets .NET 8.0, which is the runtime for PowerShell 7.4 LTS. + PowerShell 7.4 LTS is supported until November 2026. .EXAMPLE PS > .\build.ps1 -Bootstrap Check and install prerequisites for the build. .EXAMPLE - PS > .\build.ps1 -Configuration Release -Framework net462 - Build the main module with 'Release' configuration and targeting 'net462'. + PS > .\build.ps1 -Configuration Release + Build the project in Release configuration. .EXAMPLE PS > .\build.ps1 - Build the main module with the default configuration (Debug) and the default target framework (determined by the current session). + Build the main module with the default configuration (Debug) configuration. .EXAMPLE PS > .\build.ps1 -Test - Run xUnit tests with the default configuration (Debug) and the default target framework (determined by the current session). + Run xUnit tests with the default configuration. .PARAMETER Clean Clean the local repo, but keep untracked files. .PARAMETER Bootstrap @@ -23,24 +27,25 @@ Run tests. .PARAMETER Configuration The configuration setting for the build. The default value is 'Debug'. -.PARAMETER Framework - The target framework for the build. - When not specified, the target framework is determined by the current PowerShell session: - - If the current session is PowerShell Core, then use 'netcoreapp3.1' as the default target framework. - - If the current session is Windows PowerShell, then use 'net462' as the default target framework. #> -[CmdletBinding()] +[CmdletBinding(DefaultParameterSetName = 'default')] param( + [Parameter(ParameterSetName = 'cleanup')] [switch] $Clean, + + [Parameter(ParameterSetName = 'bootstrap')] [switch] $Bootstrap, + + [Parameter(ParameterSetName = 'test')] [switch] $Test, + + [Parameter(ParameterSetName = 'test')] [switch] $CheckHelpContent, + [Parameter(ParameterSetName = 'default')] + [Parameter(ParameterSetName = 'test')] [ValidateSet("Debug", "Release")] - [string] $Configuration = "Debug", - - [ValidateSet("net462", "net6.0")] - [string] $Framework + [string] $Configuration = "Debug" ) # Clean step @@ -48,16 +53,17 @@ if ($Clean) { try { Push-Location $PSScriptRoot git clean -fdX - return } finally { Pop-Location } + + return } Import-Module "$PSScriptRoot/tools/helper.psm1" if ($Bootstrap) { - Write-Log "Validate and install missing prerequisits for building ..." + Write-Log "Validate and install missing prerequisites for building ..." Install-Dotnet if (-not (Get-Module -Name InvokeBuild -ListAvailable)) { @@ -78,7 +84,6 @@ if (-not (Get-Module -Name InvokeBuild -ListAvailable)) { $buildTask = if ($Test) { "RunTests" } else { "ZipRelease" } $arguments = @{ Task = $buildTask; Configuration = $Configuration } -if ($Framework) { $arguments.Add("Framework", $Framework) } if ($CheckHelpContent) { $arguments.Add("CheckHelpContent", $true) } Invoke-Build @arguments diff --git a/global.json b/global.json new file mode 100644 index 000000000..d23a6248a --- /dev/null +++ b/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "8.0.415" + } +} diff --git a/nuget.config b/nuget.config index 654858614..a10ce9b3d 100644 --- a/nuget.config +++ b/nuget.config @@ -2,7 +2,7 @@ - + diff --git a/test/BasicEditingTest.VI.cs b/test/BasicEditingTest.VI.cs index a41b6b078..33c3ad17f 100644 --- a/test/BasicEditingTest.VI.cs +++ b/test/BasicEditingTest.VI.cs @@ -494,8 +494,6 @@ public void ViDeletePreviousLines() { TestSetup(KeyMode.Vi); - int continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\n\"", Keys( β€―_.DQuote, _.Enter, "one", _.Enter, @@ -503,7 +501,7 @@ public void ViDeletePreviousLines() "three", _.Enter, _.DQuote, _.Escape, "kl", // go to the 'hree' portion of "three" - "2dk", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + "2dk", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); } @@ -512,8 +510,6 @@ public void ViDeletePreviousLines_LastLine() { TestSetup(KeyMode.Vi); - int continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\none\ntwo\n\"", Keys( β€―_.DQuote, _.Enter, "one", _.Enter, @@ -521,7 +517,7 @@ public void ViDeletePreviousLines_LastLine() "three", _.Enter, _.DQuote, _.Escape, "dk", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), CheckThat(() => AssertCursorTopIs(2)), CheckThat(() => AssertLineIs("\"\none\ntwo")), // finish the buffer to close the multiline string @@ -571,8 +567,6 @@ public void ViDeleteToEnd() { TestSetup(KeyMode.Vi); - int continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\no\nthree\n\"", Keys( _.DQuote, _.Enter, "one", _.Enter, @@ -582,7 +576,7 @@ public void ViDeleteToEnd() "kkkl", // go to the 'ne' portion of "one" // delete to the end of the next line "2d$", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); } @@ -591,18 +585,16 @@ public void ViBackwardDeleteLine() { TestSetup(KeyMode.Vi); - int continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\nsome words\n\"", Keys( _.DQuote, _.Enter, " this is a line with some words", _.Enter, _.DQuote, _.Escape, "k6W", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 23)), + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 23)), // delete from first non blank of line "d0", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength)) + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength)) )); } @@ -611,17 +603,15 @@ public void ViDeleteLineToFirstChar() { TestSetup(KeyMode.Vi); - int continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\n some spaces\n\"", Keys( _.DQuote, _.Enter, " this is a line with some spaces", _.Enter, _.DQuote, _.Escape, "k6W", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 23)), + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 23)), // delete from first non blank of line "d^", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)) + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)) )); } @@ -630,8 +620,6 @@ public void ViDeleteNextLines() { TestSetup(KeyMode.Vi); - int continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\n\"", Keys( β€―_.DQuote, _.Enter, "one", _.Enter, @@ -639,7 +627,7 @@ public void ViDeleteNextLines() "three", _.Enter, _.DQuote, _.Escape, "kkkl", // go to the 'ne' portion of "one" - "2dj", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + "2dj", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); } @@ -648,8 +636,6 @@ public void ViDeleteRelativeLines() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\nthree\n\"", Keys( _.DQuote, _.Enter, "one", _.Enter, @@ -659,7 +645,7 @@ public void ViDeleteRelativeLines() "kkl", // go to the 'wo' portion of "two" // delete from line 2 to the current line (3) "2dgg", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); Test("\"\none\nthree\n\"", Keys( @@ -671,7 +657,7 @@ public void ViDeleteRelativeLines() "kkl", // go to the 'wo' portion of "two" // delete the current line (3) "3dgg", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); Test("\"\none\n\"", Keys( @@ -683,7 +669,7 @@ public void ViDeleteRelativeLines() "kkl", // go to the 'wo' portion of "two" // delete from the current line (3) to line 4 "4dgg", - CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); } @@ -977,7 +963,6 @@ public void ViDefect651() [SkippableFact] public void ViInsertLine() { - int adder = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; TestSetup(KeyMode.Vi); Test("line1\n", Keys( @@ -985,8 +970,8 @@ public void ViInsertLine() )); Test("\nline1", Keys( - _.Escape, "oline1", CheckThat(() => AssertCursorLeftIs(5 + adder)), CheckThat(() => AssertLineIs("\nline1")), - _.Escape, CheckThat(() => AssertCursorLeftIs(4 + adder)) + _.Escape, "oline1", CheckThat(() => AssertCursorLeftIs(5 + ContinuationPromptLength)), CheckThat(() => AssertLineIs("\nline1")), + _.Escape, CheckThat(() => AssertCursorLeftIs(4 + ContinuationPromptLength)) )); Test("", Keys( @@ -1010,10 +995,10 @@ public void ViInsertLine() )); Test("", Keys( - _.Escape, "oline4", CheckThat(() => AssertLineIs("\nline4")), CheckThat(() => AssertCursorLeftIs(5 + adder)), - _.Escape, "Oline2", CheckThat(() => AssertLineIs("\nline2\nline4")), CheckThat(() => AssertCursorLeftIs(5 + adder)), + _.Escape, "oline4", CheckThat(() => AssertLineIs("\nline4")), CheckThat(() => AssertCursorLeftIs(5 + ContinuationPromptLength)), + _.Escape, "Oline2", CheckThat(() => AssertLineIs("\nline2\nline4")), CheckThat(() => AssertCursorLeftIs(5 + ContinuationPromptLength)), _.Escape, "oline3", CheckThat(() => AssertLineIs("\nline2\nline3\nline4")), - _.Escape, CheckThat(() => AssertLineIs("\nline2\nline3\nline4")), CheckThat(() => AssertCursorLeftIs(4 + adder)), + _.Escape, CheckThat(() => AssertLineIs("\nline2\nline3\nline4")), CheckThat(() => AssertCursorLeftIs(4 + ContinuationPromptLength)), 'u', CheckThat(() => AssertLineIs("\nline2\nline4")), 'u', CheckThat(() => AssertLineIs("\nline4")), 'u' @@ -1121,5 +1106,88 @@ public void ViInsertModeMoveCursor() _.RightArrow, // 'RightArrow' again does nothing, but doesn't crash "c")); } + + [SkippableFact] + public void ViDefect1281_1() + { + TestSetup(KeyMode.Vi); + + Test("bcd", Keys( + "abcdabcd", _.Escape, + + // return to the [B]eginning of the word, + // then [c]hange text un[t]il just before the [2]nd [b] character + // this leaves the cursor at the current position (0) but erases + // the "abcda" text portion, / switches to edit mode and + // positions the cursor just before the "bcd" text portion. + + "Bc2tb", + + // going back to normal mode again without having modified the buffer further + // even though the [c] command started an edit group, going back to normal + // mode closes the pending edit group. + + _.Escape, CheckThat(() => AssertCursorLeftIs(0)), + + // attempt to [c]hange text un[t]il just before the [2]nd [b] character again + // because the [b] character only appears once further down in the buffer + // relative to where the cursor position is – currently set to 0 - the command + // fails. Therefore, we are still in normal mode. + // + // as the command failed, the current edit group is now correctly closed. + + "c2tb", CheckThat(() => AssertLineIs("bcd")), + + // attempt to [c]hange text un[t]il just before the [2]nd [b] character a third time. + // this exercises a code path where starting a edit group while another + // pending edit group was previously started crashed PSRL. + // + // this should no longer crash as any started pending group is now properly closed if + // the command fails + "c2tb" + )); + } + + [SkippableFact] + public void ViDefect1281_2() + { + TestSetup(KeyMode.Vi); + + Test("abc", Keys( + "abc", _.Escape, + + // 'cff' triggers `ViReplaceToChar` to delete until the character 'f'. But 'abc' doesn't + // have the letter 'f', so the started edit group should be ended and cursor is not moved. + "cff", + CheckThat(() => AssertCursorLeftIs(2)), + + // the subsequent 'cc' calls `ViReplaceLine` to replace the current line with 'i', which + // starts a new edit group. + "ccip", + CheckThat(() => AssertLineIs("ip")), + + // now we undo the 'cci' step, and accept the current command line, which should be 'abc'. + _.Escape, "u" + )); + } + + [SkippableFact] + public void ViDefect1281_3() + { + TestSetup(KeyMode.Vi); + + Test("bcd", Keys( + "bcd", _.Escape, _.LeftArrow, _.LeftArrow, + + // 'a' triggers `ViInsertWithAppend` and we append 'iii' after 'b'. + "aiii", + CheckThat(() => AssertCursorLeftIs(4)), + CheckThat(() => AssertLineIs("biiicd")), + + // now we undo the 'aiii' step, and accept the current command line, + // which should be 'bcd'. + _.Escape, "u" + )); + } } } diff --git a/test/BasicEditingTest.cs b/test/BasicEditingTest.cs index dd5bc357e..ac5c7836f 100644 --- a/test/BasicEditingTest.cs +++ b/test/BasicEditingTest.cs @@ -200,6 +200,37 @@ public void DeleteCharOrExit() Test("exit", Keys("foo", _.Home, Enumerable.Repeat(_.Ctrl_d, 4), InputAcceptedNow)); } + [SkippableFact] + public void UpcaseWord() + { + TestSetup(KeyMode.Emacs); + Test("FOO", Keys("foo", _.Alt_b, _.Alt_u)); + Test("FOO bar", Keys("foo bar", _.Home, _.Alt_u)); + Test("foo BAR", Keys("foo bar", _.Alt_b, _.Alt_u)); + Test("FOO BAR", Keys("foo bar", _.Home, Enumerable.Repeat(_.Alt_u, 2))); + } + + [SkippableFact] + public void DowncaseWord() + { + TestSetup(KeyMode.Emacs); + Test("foo", Keys("fOO", _.Alt_b, _.Alt_l)); + Test("FOO bar", Keys("FOO BAR", _.Alt_b, _.Alt_l)); + Test("foo BAR", Keys("FOO BAR", _.Home, _.Alt_l)); + Test("foo bar", Keys("FOO BAR", _.Home, Enumerable.Repeat(_.Alt_l, 2))); + } + + [SkippableFact] + public void CapitalizeWord() + { + TestSetup(KeyMode.Emacs); + Test("Foo", Keys("fOO", _.Alt_b, _.Alt_c)); + Test("fOO Bar", Keys("fOO bAR", _.Alt_b, _.Alt_c)); + Test("Foo BAR", Keys("fOO BAR", _.Home, _.Alt_c)); + Test("Foo Bar", Keys("fOO BAR", _.Home, Enumerable.Repeat(_.Alt_c, 2))); + Test("Foo ^&*() Bar", Keys("Foo ^&*() bar", _.Home, Enumerable.Repeat(_.Alt_c, 2))); + } + [SkippableFact] public void SelectAndDelete() { @@ -309,8 +340,6 @@ public void InsertLineAbove() { TestSetup(KeyMode.Cmd); - var continutationPromptLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - // Test case - start with single line, cursor at end Test("56\n1234", Keys("1234", _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(0, 0)), "56")); @@ -325,13 +354,13 @@ public void InsertLineAbove() // Test case - start with multi-line, cursor at end of second line (end of input) Test("1234\n9ABC\n5678", Keys("1234", _.Shift_Enter, "5678", - _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "9ABC")); // Test case - start with multi-line, cursor at beginning of second line Test("1234\n9ABC\n5678", Keys("1234", _.Shift_Enter, "5678", - _.LeftArrow, _.Home, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), - _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.LeftArrow, _.Home, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "9ABC")); // Test case - start with multi-line, cursor at end of first line @@ -349,15 +378,15 @@ public void InsertLineAbove() // Test case - insert multiple blank lines Test("1234\n9ABC\n\n5678", Keys("1234", _.Shift_Enter, "5678", - _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), - _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "9ABC")); // Test case - create leading blank line, cursor to stay on same line Test("\n\n1234", Keys("1234", _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(0,0)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), - _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)))); + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.Ctrl_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)))); } [SkippableFact] @@ -365,51 +394,49 @@ public void InsertLineBelow() { TestSetup(KeyMode.Cmd); - var continutationPromptLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - // Test case - start with single line, cursor at end Test("1234\n56", Keys("1234", - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "56")); // Test case - start with single line, cursor in home position Test("1234\n56", Keys("1234", - _.Home, _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.Home, _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "56")); // Test case - start with single line, cursor in middle Test("1234\n56", Keys("1234", - _.LeftArrow, _.LeftArrow, _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.LeftArrow, _.LeftArrow, _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "56")); // Test case - start with multi-line, cursor at end of second line (end of input) Test("1234\n5678\n9ABC", Keys("1234", _.Shift_Enter, "5678", - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 2)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 2)), "9ABC")); // Test case - start with multi-line, cursor at beginning of second line Test("1234\n5678\n9ABC", Keys("1234", _.Shift_Enter, "5678", - _.LeftArrow, _.Home, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 2)), + _.LeftArrow, _.Home, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 2)), "9ABC")); // Test case - start with multi-line, cursor at end of first line Test("1234\n9ABC\n5678", Keys("1234", _.Shift_Enter, "5678", _.UpArrow, _.LeftArrow, _.End, CheckThat(() => AssertCursorLeftTopIs(4, 0)), - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "9ABC")); // Test case - start with multi-line, cursor at beginning of first line - temporarily having to press Home twice to // work around bug in home handler. Test("1234\n9ABC\n5678", Keys("1234", _.Shift_Enter, "5678", _.UpArrow, _.LeftArrow, _.Home, _.Home, CheckThat(() => AssertCursorLeftTopIs(0, 0)), - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), "9ABC")); // Test case - insert multiple blank lines Test("1234\n5678\n\n9ABC", Keys("1234", _.Shift_Enter, "5678", - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 2)), - _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 3)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 2)), + _.Ctrl_Shift_Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 3)), "9ABC")); } diff --git a/test/CompletionTest.cs b/test/CompletionTest.cs index c2f6bad1e..8287e58c9 100644 --- a/test/CompletionTest.cs +++ b/test/CompletionTest.cs @@ -177,6 +177,8 @@ public void MenuCompletions_FilterByTyping() [SkippableFact] public void MenuCompletions_Navigation1() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // Test 'RightArrow' and 'LeftArrow' with the following menu: // Get-Many0 Get-Many3 Get-Many6 Get-Many9 Get-Many12 // Get-Many1 Get-Many4 Get-Many7 Get-Many10 Get-Many13 @@ -304,6 +306,8 @@ public void MenuCompletions_Navigation1() [SkippableFact] public void MenuCompletions_Navigation2() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // Test 'RightArrow' with the following menu: // Get-Less0 Get-Less3 Get-Less6 Get-Less9 Get-Less12 // Get-Less1 Get-Less4 Get-Less7 Get-Less10 @@ -387,6 +391,8 @@ public void MenuCompletions_Navigation2() [SkippableFact] public void MenuCompletions_Navigation3() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // Test 'LeftArrow' with the following menu: // Get-Less0 Get-Less3 Get-Less6 Get-Less9 Get-Less12 // Get-Less1 Get-Less4 Get-Less7 Get-Less10 @@ -462,6 +468,8 @@ public void MenuCompletions_Navigation3() [SkippableFact] public void MenuCompletions_Navigation4() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // Test 'UpArrow' and 'DownArrow' with the following menu: // Get-Less0 Get-Less3 Get-Less6 Get-Less9 Get-Less12 // Get-Less1 Get-Less4 Get-Less7 Get-Less10 @@ -633,6 +641,8 @@ public void MenuCompletions_Navigation5() [SkippableFact] public void MenuCompletions_Navigation6() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // Test 'UpArrow', 'DownArrow', 'LeftArrow', and 'RightArrow' with the following menu: // Get-NewDynamicParameters Get-NewStyle // Get-NewIdea @@ -736,6 +746,8 @@ public void MenuCompletions_Navigation6() [SkippableFact] public void MenuCompletions_Navigation7() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // Trigger the menu completion from the last line in the screen buffer, which will cause the screen // to scroll up. Then test 'DownArrow' and 'UpArrow' with the following menu to verify if scrolling // was handled correctly: @@ -968,20 +980,27 @@ public void MenuCompletions_ClearProperly() [SkippableFact] public void MenuCompletions_WorkWithListView() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+Spacebar", PSConsoleReadLine.MenuComplete)); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); _console.Clear(); - SetHistory("Get-Mocha -AddMilk -AddSugur -ExtraCup", "Get-MoreBook -Kind Fiction -FlatCover"); + SetHistory("Get-Mocha -AddMilk -AddSugar -ExtraCup", "Get-MoreBook -Kind Fiction -FlatCover"); Test("Get-Module", Keys( "Get-Mo", - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "Get-Mo", NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "Get-Mo", @@ -994,8 +1013,8 @@ public void MenuCompletions_WorkWithListView() TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "Get-Mo", - TokenClassification.None, "cha -AddMilk -AddSugur -ExtraCup", - TokenClassification.None, new string(' ', listWidth - 49), // 49 is the length of '> Get-Mocha -AddMilk -AddSugur -ExtraCup' plus '[History]'. + TokenClassification.None, "cha -AddMilk -AddSugar -ExtraCup", + TokenClassification.None, new string(' ', listWidth - 49), // 49 is the length of '> Get-Mocha -AddMilk -AddSugar -ExtraCup' plus '[History]'. TokenClassification.None, '[', TokenClassification.ListPrediction, "History", TokenClassification.None, ']')), @@ -1027,6 +1046,8 @@ public void MenuCompletions_WorkWithListView() [SkippableFact] public void MenuCompletions_HandleScrolling1() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // This test case covers the fix to https://github.com/PowerShell/PSReadLine/issues/2928. var basicScrollingConsole = new BasicScrollingConsole(keyboardLayout: _, width: 133, height: 10); TestSetup(basicScrollingConsole, KeyMode.Cmd, new KeyHandler("Ctrl+Spacebar", PSConsoleReadLine.MenuComplete)); @@ -1122,6 +1143,8 @@ public void MenuCompletions_HandleScrolling1() [SkippableFact] public void MenuCompletions_HandleScrolling2() { + Skip.If(ScreenReaderModeEnabled, "Menu completions are not supported in screen reader mode."); + // This test case covers the fix to https://github.com/PowerShell/PSReadLine/issues/2948. var basicScrollingConsole = new BasicScrollingConsole(keyboardLayout: _, width: 133, height: 10); TestSetup(basicScrollingConsole, KeyMode.Cmd, new KeyHandler("Ctrl+Spacebar", PSConsoleReadLine.MenuComplete)); @@ -1319,6 +1342,95 @@ public void DirectoryCompletion() _.Ctrl_c, InputAcceptedNow)); } + [SkippableFact] + public void MenuCompletions_Backspace() + { + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+Spacebar", PSConsoleReadLine.MenuComplete)); + + _console.Clear(); + char separator = Path.DirectorySeparatorChar; + + Test("cd stro", Keys( + "cd stron", _.Ctrl_Spacebar, + CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strong", + TokenClassification.Selection, separator, + NextLine, + TokenClassification.Selection, "strong ", + TokenClassification.None, "stronghold strongholp", + NextLine, + NextLine)), + _.h, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strongh", + TokenClassification.Selection, $"old{separator}", + NextLine, + TokenClassification.Selection, "stronghold ", + TokenClassification.None, "strongholp", + NextLine, + NextLine)), + // Tab will update the user completion text to include the unambiguous common prefix. + _.Tab, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}stronghol", + TokenClassification.Selection, $"d{separator}", + NextLine, + TokenClassification.Selection, "stronghold ", + TokenClassification.None, "strongholp", + NextLine, + NextLine)), + _.Backspace, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strongho", + TokenClassification.Selection, $"ld{separator}", + NextLine, + TokenClassification.Selection, "stronghold ", + TokenClassification.None, "strongholp", + NextLine, + NextLine)), + _.Backspace, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strongh", + TokenClassification.Selection, $"old{separator}", + NextLine, + TokenClassification.Selection, "stronghold ", + TokenClassification.None, "strongholp", + NextLine, + NextLine)), + _.Backspace, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strong", + TokenClassification.Selection, separator, + NextLine, + TokenClassification.Selection, "strong ", + TokenClassification.None, "stronghold strongholp", + NextLine, + NextLine)), + _.h, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strongh", + TokenClassification.Selection, $"old{separator}", + NextLine, + TokenClassification.Selection, "stronghold ", + TokenClassification.None, "strongholp", + NextLine, + NextLine)), + _.Backspace, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "cd", + TokenClassification.None, $" .{separator}strong", + TokenClassification.Selection, separator, + NextLine, + TokenClassification.Selection, "strong ", + TokenClassification.None, "stronghold strongholp", + NextLine, + NextLine)), + _.Backspace, + _.Backspace, CheckThat(() => AssertLineIs("cd stro")), + _.Enter + )); + } + internal static CommandCompletion MockedCompleteInput(string input, int cursor, Hashtable options, PowerShell powerShell) { var ctor = typeof (CommandCompletion).GetConstructor( @@ -1419,6 +1531,14 @@ internal static CommandCompletion MockedCompleteInput(string input, int cursor, break; case "none": break; + case "cd stron": + replacementIndex = 3; + replacementLength = 5; + char separator = Path.DirectorySeparatorChar; + completions.Add(new CompletionResult($".{separator}strong", "strong", CompletionResultType.ProviderContainer, $".{separator}strong")); + completions.Add(new CompletionResult($".{separator}stronghold", "stronghold", CompletionResultType.ProviderContainer, $".{separator}stronghold")); + completions.Add(new CompletionResult($".{separator}strongholp", "strongholp", CompletionResultType.ProviderContainer, $".{separator}strongholp")); + break; default: if (input.EndsWith("Get-Mo", StringComparison.OrdinalIgnoreCase)) diff --git a/test/HistoryTest.cs b/test/HistoryTest.cs index 6d724e005..ad6b95f0c 100644 --- a/test/HistoryTest.cs +++ b/test/HistoryTest.cs @@ -202,7 +202,24 @@ public void SensitiveHistoryDefaultBehavior_Two() "Set-SecretInfo -Name apikey; Set-SecretVaultDefault; Test-SecretVault; Unlock-SecretVault -password $pwd; Unregister-SecretVault -SecretVault vaultInfo", "Get-ResultFromTwo -Secret1 (Get-Secret -Name blah -AsPlainText) -Secret2 $secret2", "Get-ResultFromTwo -Secret1 (Get-Secret -Name blah -AsPlainText) -Secret2 sdv87ysdfayf798hfasd8f7ha", // '-Secret2' has expr-value argument. Not saved to file. - "$environment -brand $brand -userBitWardenEmail $bwuser -userBitWardenPassword $bwpass" // '-userBitWardenPassword' matches sensitive pattern and it has parsing error. Not save to file. + "$environment -brand $brand -userBitWardenEmail $bwuser -userBitWardenPassword $bwpass", // '-userBitWardenPassword' matches sensitive pattern and it has parsing error. Not save to file. + "(Import-Clixml \"${Env:HOME}\\credential.clixml\").GetNetworkCredential().Password | Set-Clipboard", // 'Password' is a property not in assignment. + "$a.Password = 'abcd'", // setting the 'Password' property with string value. Not saved to file. + "$a.Password.Value = 'abcd'", // indirectly setting the 'Password' property with string value. Not saved to file. + "$a.Secret = Get-Secret -Name github-token -Vault MySecret", + "$a.Secret = $secret", + "$a.Password = 'ab' + 'cd'", // setting the 'Password' property with string values. Not saved to file. + "$a.Password.Secret | Set-Value", + "Write-Host $a.Password.Secret", + "($a.Password, $b) = ('aa', 'bb')", // setting the 'Password' property with string value. Not saved to file. + "kubectl get secrets", + "kubectl get secret db-user-pass -o jsonpath='{.data.password}' | base64 --decode", + "kubectl describe secret db-user-pass", + "(Get-AzAccessToken -ResourceUrl 'https://abc.com').Token", + "$token = (Get-AzAccessToken -ResourceUrl 'abc').Token", + "az account get-access-token --resource=https://abc.com --query accessToken --output tsv", + "curl -X GET --header \"Authorization: Bearer $token\" https://abc.com", + "$env:PGPASS = gcloud auth print-access-token", }; string[] expectedSavedItems = new[] { @@ -218,6 +235,19 @@ public void SensitiveHistoryDefaultBehavior_Two() "Get-SecretInfo -Name mytoken; Get-SecretVault; Register-SecretVault; Remove-Secret apikey", "Set-SecretInfo -Name apikey; Set-SecretVaultDefault; Test-SecretVault; Unlock-SecretVault -password $pwd; Unregister-SecretVault -SecretVault vaultInfo", "Get-ResultFromTwo -Secret1 (Get-Secret -Name blah -AsPlainText) -Secret2 $secret2", + "(Import-Clixml \"${Env:HOME}\\credential.clixml\").GetNetworkCredential().Password | Set-Clipboard", + "$a.Secret = Get-Secret -Name github-token -Vault MySecret", + "$a.Secret = $secret", + "$a.Password.Secret | Set-Value", + "Write-Host $a.Password.Secret", + "kubectl get secrets", + "kubectl get secret db-user-pass -o jsonpath='{.data.password}' | base64 --decode", + "kubectl describe secret db-user-pass", + "(Get-AzAccessToken -ResourceUrl 'https://abc.com').Token", + "$token = (Get-AzAccessToken -ResourceUrl 'abc').Token", + "az account get-access-token --resource=https://abc.com --query accessToken --output tsv", + "curl -X GET --header \"Authorization: Bearer $token\" https://abc.com", + "$env:PGPASS = gcloud auth print-access-token", }; try diff --git a/test/InlinePredictionTest.cs b/test/InlinePredictionTest.cs index 9c0edd752..db6145164 100644 --- a/test/InlinePredictionTest.cs +++ b/test/InlinePredictionTest.cs @@ -14,6 +14,8 @@ public partial class ReadLine [SkippableFact] public void Inline_RenderSuggestion() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.InlineView); @@ -92,6 +94,8 @@ public void Inline_RenderSuggestion() [SkippableFact] public void Inline_CustomKeyBindingsToAcceptSuggestion() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Alt+g", PSConsoleReadLine.AcceptSuggestion), new KeyHandler("Alt+f", PSConsoleReadLine.AcceptNextSuggestionWord)); @@ -156,6 +160,8 @@ public void Inline_CustomKeyBindingsToAcceptSuggestion() [SkippableFact] public void Inline_AcceptNextSuggestionWordCanAcceptMoreThanOneWords() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord), new KeyHandler("Alt+f", PSConsoleReadLine.AcceptNextSuggestionWord)); @@ -192,6 +198,8 @@ public void Inline_AcceptNextSuggestionWordCanAcceptMoreThanOneWords() [SkippableFact] public void Inline_AcceptSuggestionWithSelection() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.InlineView); @@ -261,6 +269,8 @@ public void Inline_DisablePrediction() [SkippableFact] public void Inline_SetPredictionColor() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); var predictionColor = MakeCombinedColor(ConsoleColor.DarkYellow, ConsoleColor.Yellow); var predictionColorToCheck = Tuple.Create(ConsoleColor.DarkYellow, ConsoleColor.Yellow); @@ -288,6 +298,8 @@ public void Inline_SetPredictionColor() [SkippableFact] public void Inline_HistoryEditsCanUndoProperly() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); SetHistory("git checkout -b branch origin/bbbb"); @@ -320,6 +332,8 @@ public void Inline_HistoryEditsCanUndoProperly() [SkippableFact] public void Inline_AcceptSuggestionInVIMode() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Vi); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.InlineView); @@ -367,6 +381,8 @@ public void Inline_AcceptSuggestionInVIMode() [SkippableFact] public void ViDefect2408() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(KeyMode.Vi); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.InlineView); @@ -395,8 +411,10 @@ public void ViDefect2408() } private const uint MiniSessionId = 56; + private static readonly Guid predictorId_0 = Guid.Parse("c69fa9fc-6157-4877-8cf4-a9c83a0128af"); private static readonly Guid predictorId_1 = Guid.Parse("b45b5fbe-90fa-486c-9c87-e7940fdd6273"); private static readonly Guid predictorId_2 = Guid.Parse("74a86463-033b-44a3-b386-41ee191c94be"); + private static readonly Guid predictorId_3 = Guid.Parse("19e98622-99e0-41f7-9ee0-a8bed92cde51"); /// /// Mocked implementation of 'PredictInput'. @@ -408,10 +426,24 @@ internal static List MockedPredictInput(Ast ast, Token[] token new[] { typeof(Guid), typeof(string), typeof(uint), typeof(List) }, null); var input = ast.Extent.Text; - if (input == "netsh") + if (input is "netsh") { return null; } + else if (input is "tooltip") + { + var suggestions_0 = new List + { + new PredictiveSuggestion("tooltip NO1", "Hello\nBinary\nWorld\nPowerShell is a task automation and configuration management program from Microsoft"), + new PredictiveSuggestion("tooltip NO2", "Hello\nWorld"), + }; + + return new List + { + (PredictionResult)ctor.Invoke( + new object[] { predictorId_0, "Tooltip", MiniSessionId, suggestions_0 }), + }; + } var suggestions_1 = new List { @@ -423,18 +455,29 @@ internal static List MockedPredictInput(Ast ast, Token[] token new PredictiveSuggestion($"SOME NEW TEXT"), }; - return new List + var result = new List { (PredictionResult)ctor.Invoke( new object[] { predictorId_1, "TestPredictor", MiniSessionId, suggestions_1 }), (PredictionResult)ctor.Invoke( new object[] { predictorId_2, "LongNamePredictor", MiniSessionId, suggestions_2 }), }; + + // Return an extra source if it's for testing the metadata line. + if (input == "metadata-line") + { + result.Add((PredictionResult)ctor.Invoke( + new object[] { predictorId_3, "Metadata", MiniSessionId, suggestions_2 })); + } + + return result; } [SkippableFact] public void Inline_PluginSource_Acceptance() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + // Using the 'Plugin' source will make PSReadLine get prediction from the plugin only. TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+f", PSConsoleReadLine.ForwardWord)); @@ -509,6 +552,8 @@ public void Inline_PluginSource_Acceptance() [SkippableFact] public void Inline_HistoryAndPluginSource_Acceptance() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + // Using the 'HistoryAndPlugin' source will make PSReadLine get prediction from the plugin and history, // and plugin takes precedence. TestSetup(KeyMode.Cmd, @@ -593,8 +638,50 @@ public void Inline_HistoryAndPluginSource_Acceptance() Assert.Equal(Guid.Empty, _mockedMethods.acceptedPredictorId); Assert.Null(_mockedMethods.acceptedSuggestion); Assert.NotNull(_mockedMethods.commandHistory); - Assert.Equal(1, _mockedMethods.commandHistory.Count); + Assert.Single(_mockedMethods.commandHistory); + Assert.Equal("netsh show me", _mockedMethods.commandHistory[0]); + + _mockedMethods.ClearPredictionFields(); + SetHistory("netsh show me"); + Test("netsh SOME TEXT AFTER", Keys( + "netsh", CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "netsh", + TokenClassification.InlinePrediction, " show me")), + // Yeah, we still have `OnSuggestionDisplayed` fired, from the typing of each character of `nets`. + CheckThat(() => AssertDisplayedSuggestions(count: 1, predictorId_1, MiniSessionId, countOrIndex: -1)), + CheckThat(() => _mockedMethods.ClearPredictionFields()), + + // Now mimic pressing a space key. This will trigger the refreshing of suggestions even though the + // current history suggestion still applies to the new input, because plugin is in use and we favor + // plugin over history results. + ' ', CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "netsh", + TokenClassification.None, " ", + TokenClassification.InlinePrediction, " SOME TEXT AFTER")), + CheckThat(() => AssertDisplayedSuggestions(count: 1, predictorId_1, MiniSessionId, countOrIndex: -1)), + CheckThat(() => Assert.Equal(Guid.Empty, _mockedMethods.acceptedPredictorId)), + CheckThat(() => Assert.Null(_mockedMethods.acceptedSuggestion)), + CheckThat(() => Assert.Null(_mockedMethods.commandHistory)), + + CheckThat(() => _mockedMethods.ClearPredictionFields()), + // 'RightArrow' will trigger 'OnSuggestionAccepted' as the suggestion is now from plugin. + _.RightArrow, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "netsh", + TokenClassification.None, " SOME TEXT AFTER")), + CheckThat(() => Assert.Empty(_mockedMethods.displayedSuggestions)), + CheckThat(() => Assert.Equal(predictorId_1, _mockedMethods.acceptedPredictorId)), + CheckThat(() => Assert.Equal("netsh SOME TEXT AFTER", _mockedMethods.acceptedSuggestion)), + CheckThat(() => Assert.Null(_mockedMethods.commandHistory)) + )); + + Assert.Empty(_mockedMethods.displayedSuggestions); + Assert.Equal(predictorId_1, _mockedMethods.acceptedPredictorId); + Assert.Equal("netsh SOME TEXT AFTER", _mockedMethods.acceptedSuggestion); + Assert.NotNull(_mockedMethods.commandHistory); + Assert.Equal(2, _mockedMethods.commandHistory.Count); Assert.Equal("netsh show me", _mockedMethods.commandHistory[0]); + Assert.Equal("netsh SOME TEXT AFTER", _mockedMethods.commandHistory[1]); + _mockedMethods.ClearPredictionFields(); } [SkippableFact] @@ -731,6 +818,8 @@ public void Inline_HistoryAndPluginSource_ExecutionStatus() [SkippableFact] public void Inline_TruncateVeryLongSuggestion() { + Skip.If(ScreenReaderModeEnabled, "Inline predictions are not supported in screen reader mode."); + TestSetup(new TestConsole(keyboardLayout: _, width: 10, height: 2), KeyMode.Cmd); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.InlineView); diff --git a/test/KeyInfo-en-US-windows.json b/test/KeyInfo-en-US-windows.json index 53243ab27..c0fcc65db 100644 --- a/test/KeyInfo-en-US-windows.json +++ b/test/KeyInfo-en-US-windows.json @@ -831,7 +831,7 @@ "Key": "Ctrl+PageUp", "KeyChar": "\u0000", "ConsoleKey": "PageUp", - "Modifiers": "0" + "Modifiers": "Control" }, { "Key": "Ctrl+q", diff --git a/test/KillYankTest.cs b/test/KillYankTest.cs index 3433f7dab..5c631d5e2 100644 --- a/test/KillYankTest.cs +++ b/test/KillYankTest.cs @@ -119,6 +119,8 @@ public void BackwardKillLine() PSConsoleReadLine.SetKeyHandler(new[] { "Shift+Tab" }, PSConsoleReadLine.BackwardKillLine, "", ""); Test("", Keys("dir", _.Shift_Tab)); + + Test("abc\n123", Keys("abc", _.Shift_Enter, "123", _.Shift_Tab, _.Ctrl_y)); } [SkippableFact] @@ -619,11 +621,65 @@ public void SelectCommandArgument_VariousArgs() _.Escape)); } + [SkippableFact] + public void SelectCommandArgument_CLIArgs() + { + TestSetup(KeyMode.Cmd); + + Test("", Keys( + "az webapp --name MyWebApp --resource-group MyResourceGroup", + _.Alt_a, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "az", + TokenClassification.None, ' ', + TokenClassification.Selection, "webapp", + TokenClassification.None, ' ', + TokenClassification.Parameter, "--name", + TokenClassification.None, " MyWebApp ", + TokenClassification.Parameter, "--resource-group", + TokenClassification.None, " MyResourceGroup ")), + + _.Alt_a, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "az", + TokenClassification.None, " webapp ", + TokenClassification.Parameter, "--name", + TokenClassification.None, ' ', + TokenClassification.Selection, "MyWebApp", + TokenClassification.None, ' ', + TokenClassification.Parameter, "--resource-group", + TokenClassification.None, " MyResourceGroup ")), + + _.Alt_a, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "az", + TokenClassification.None, " webapp ", + TokenClassification.Parameter, "--name", + TokenClassification.None, " MyWebApp ", + TokenClassification.Parameter, "--resource-group", + TokenClassification.None, ' ', + TokenClassification.Selection, "MyResourceGroup")), + + // Verify that we can loop through the arguments. + _.Alt_a, CheckThat(() => AssertScreenIs(1, + TokenClassification.Command, "az", + TokenClassification.None, ' ', + TokenClassification.Selection, "webapp", + TokenClassification.None, ' ', + TokenClassification.Parameter, "--name", + TokenClassification.None, " MyWebApp ", + TokenClassification.Parameter, "--resource-group", + TokenClassification.None, " MyResourceGroup ")), + + _.Escape)); + } + [SkippableFact] public void SelectCommandArgument_HereStringArgs() { + Skip.If(ScreenReaderModeEnabled, "We're still investigating exactly why this test fails in screen reader mode."); + TestSetup(KeyMode.Cmd); + var continuationPrompt = PSConsoleReadLine.GetOptions().ContinuationPrompt; + Test("", Keys( "& Test-Sca a1 @'\nabc\n'@ -p1 \"$false\"", // Command name or command expression should be skipped. @@ -635,10 +691,10 @@ public void SelectCommandArgument_HereStringArgs() TokenClassification.None, ' ', TokenClassification.String, "@'", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "abc", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "'@", TokenClassification.None, ' ', TokenClassification.Parameter, "-p1", @@ -654,10 +710,10 @@ public void SelectCommandArgument_HereStringArgs() TokenClassification.None, " a1 ", TokenClassification.String, "@'", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.Selection, "abc", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "'@", TokenClassification.None, ' ', TokenClassification.Parameter, "-p1", @@ -673,10 +729,10 @@ public void SelectCommandArgument_HereStringArgs() TokenClassification.None, " a1 ", TokenClassification.String, "@'", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "abc", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "'@", TokenClassification.None, ' ', TokenClassification.Parameter, "-p1", @@ -694,10 +750,10 @@ public void SelectCommandArgument_HereStringArgs() TokenClassification.None, ' ', TokenClassification.String, "@'", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "abc", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "'@", TokenClassification.None, ' ', TokenClassification.Parameter, "-p1", @@ -716,10 +772,10 @@ public void SelectCommandArgument_HereStringArgs() TokenClassification.None, ' ', TokenClassification.String, "@'", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "abc", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "'@", TokenClassification.None, ' ', TokenClassification.Parameter, "-p1", @@ -736,10 +792,10 @@ public void SelectCommandArgument_HereStringArgs() TokenClassification.None, " a1 ", TokenClassification.String, "@'", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "abc", NextLine, - TokenClassification.None, ">> ", + TokenClassification.None, continuationPrompt, TokenClassification.String, "'@", TokenClassification.None, ' ', TokenClassification.Parameter, "-p1", diff --git a/test/ListPredictionTest.cs b/test/ListPredictionTest.cs index 1a4ee2f9a..a0a426962 100644 --- a/test/ListPredictionTest.cs +++ b/test/ListPredictionTest.cs @@ -8,7 +8,7 @@ public partial class ReadLine { // The source of truth is defined in 'Microsoft.PowerShell.PSConsoleReadLine+PredictionListView'. // Make sure the values are in sync. - private const int MinWindowWidth = 54; + private const int MinWindowWidth = 50; private const int MinWindowHeight = 15; private const int ListMaxWidth = 100; private const int SourceMaxWidth = 15; @@ -19,8 +19,8 @@ private int CheckWindowSize() // This is a precaution check, just in case that things change. int winWidth = _console.WindowWidth; int winHeight = _console.WindowHeight; - Assert.True(winWidth >= 54, $"list-view prediction requires minimum window width {MinWindowWidth}. Make sure the TestConsole's width is set properly."); - Assert.True(winHeight >= 15, $"list-view prediction requires minimum window height {MinWindowHeight}. Make sure the TestConsole's height is set properly."); + Assert.True(winWidth >= MinWindowWidth, $"list-view prediction requires minimum window width {MinWindowWidth}. Make sure the TestConsole's width is set properly."); + Assert.True(winHeight >= MinWindowHeight, $"list-view prediction requires minimum window height {MinWindowHeight}. Make sure the TestConsole's height is set properly."); int listWidth = winWidth > ListMaxWidth ? ListMaxWidth : winWidth; return listWidth; @@ -37,6 +37,16 @@ private Disposable SetPrediction(PredictionSource source, PredictionViewStyle vi new SetPSReadLineOption { PredictionSource = oldSource, PredictionViewStyle = oldView })); } + private Disposable SetHistorySearchCaseSensitive(bool caseSensitive) + { + var options = PSConsoleReadLine.GetOptions(); + var oldValue = options.HistorySearchCaseSensitive; + + PSConsoleReadLine.SetOptions(new SetPSReadLineOption { HistorySearchCaseSensitive = caseSensitive }); + return new Disposable(() => PSConsoleReadLine.SetOptions( + new SetPSReadLineOption { HistorySearchCaseSensitive = oldValue })); + } + private void AssertDisplayedSuggestions(int count, Guid predictorId, uint session, int countOrIndex) { Assert.Equal(count, _mockedMethods.displayedSuggestions.Count); @@ -86,17 +96,26 @@ public void List_RenderSuggestion_NoMatching_HistorySearchBackwardForward() [SkippableFact] public void List_RenderSuggestion_ListUpdatesWhileTyping() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + // The font effect sequences of the dimmed color used in list view metadata line + // are ignored in the mock console, so only the white color will be left. + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); // Different matches as more input coming SetHistory("echo -bar", "eca -zoo"); Test("ech", Keys( - 'e', CheckThat(() => AssertScreenIs(3, + 'e', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -115,9 +134,13 @@ public void List_RenderSuggestion_ListUpdatesWhileTyping() TokenClassification.ListPrediction, "History", TokenClassification.None, ']' )), - 'c', CheckThat(() => AssertScreenIs(3, + 'c', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "ec", NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "ec", @@ -136,9 +159,13 @@ public void List_RenderSuggestion_ListUpdatesWhileTyping() TokenClassification.ListPrediction, "History", TokenClassification.None, ']' )), - 'h', CheckThat(() => AssertScreenIs(2, + 'h', CheckThat(() => AssertScreenIs(3, TokenClassification.Command, "ech", NextLine, + TokenClassification.ListPrediction, "<-/1>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/1>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "ech", @@ -159,17 +186,24 @@ public void List_RenderSuggestion_ListUpdatesWhileTyping() [SkippableFact] public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); // Navigate up and down in the list SetHistory("echo -bar", "eca -zoo"); Test("e", Keys( - 'e', CheckThat(() => AssertScreenIs(3, + 'e', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -189,11 +223,17 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() TokenClassification.None, ']' )), _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -213,11 +253,17 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() TokenClassification.None, ']' )), _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<2/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<2/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(2/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -237,9 +283,13 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() TokenClassification.ListPredictionSelected, ']' )), _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -259,11 +309,17 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() TokenClassification.None, ']' )), _.UpArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<2/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<2/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(2/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -283,11 +339,17 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() TokenClassification.ListPredictionSelected, ']' )), _.UpArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -307,9 +369,13 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() TokenClassification.None, ']' )), _.UpArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -339,19 +405,26 @@ public void List_RenderSuggestion_NavigateInList_DefaultUpArrowDownArrow() [SkippableFact] public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+p", PSConsoleReadLine.HistorySearchBackward), new KeyHandler("Ctrl+l", PSConsoleReadLine.HistorySearchForward)); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); // Navigate up and down in the list SetHistory("echo -bar", "eca -zoo"); Test("e", Keys( - 'e', CheckThat(() => AssertScreenIs(3, + 'e', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -371,11 +444,17 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() TokenClassification.None, ']' )), _.Ctrl_l, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -395,11 +474,17 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() TokenClassification.None, ']' )), _.Ctrl_l, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<2/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<2/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(2/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -419,9 +504,13 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() TokenClassification.ListPredictionSelected, ']' )), _.Ctrl_l, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -441,11 +530,17 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() TokenClassification.None, ']' )), _.Ctrl_p, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<2/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<2/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(2/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -465,11 +560,17 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() TokenClassification.ListPredictionSelected, ']' )), _.Ctrl_p, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -489,9 +590,13 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() TokenClassification.None, ']' )), _.Ctrl_p, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -521,17 +626,24 @@ public void List_RenderSuggestion_NavigateInList_HistorySearchBackwardForward() [SkippableFact] public void List_RenderSuggestion_Escape() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); // Press 'Escape' without selecting an item. SetHistory("echo -bar", "eca -zoo"); Test("echo -bar", Keys( - 'c', CheckThat(() => AssertScreenIs(3, + 'c', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'c', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -558,9 +670,13 @@ public void List_RenderSuggestion_Escape() TokenClassification.None, new string(' ', listWidth) )), // Keep typing will trigger the list view again - 'h', CheckThat(() => AssertScreenIs(2, + 'h', CheckThat(() => AssertScreenIs(3, TokenClassification.Command, "ch", NextLine, + TokenClassification.ListPrediction, "<-/1>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/1>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, "ch", @@ -571,11 +687,17 @@ public void List_RenderSuggestion_Escape() TokenClassification.None, ']' )), _.DownArrow, - CheckThat(() => AssertScreenIs(2, + CheckThat(() => AssertScreenIs(3, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<1/1>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/1>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/1)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, " e", emphasisColors, "ch", @@ -597,9 +719,13 @@ public void List_RenderSuggestion_Escape() // Press 'Escape' after selecting an item. SetHistory("echo -bar", "eca -zoo"); Test("c", Keys( - 'c', CheckThat(() => AssertScreenIs(3, + 'c', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'c', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -619,11 +745,17 @@ public void List_RenderSuggestion_Escape() TokenClassification.None, ']' )), _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, " e", emphasisColors, 'c', @@ -659,16 +791,23 @@ public void List_RenderSuggestion_Escape() [SkippableFact] public void List_RenderSuggestion_DigitArgument() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); SetHistory("echo -bar", "eca -zoo"); Test("c", Keys( - 'c', CheckThat(() => AssertScreenIs(3, + 'c', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'c', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -688,9 +827,13 @@ public void List_RenderSuggestion_DigitArgument() TokenClassification.None, ']' )), _.Alt_2, - CheckThat(() => AssertScreenIs(4, + CheckThat(() => AssertScreenIs(5, TokenClassification.Command, 'c', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -712,11 +855,17 @@ public void List_RenderSuggestion_DigitArgument() TokenClassification.None, "digit-argument: 2" )), _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<2/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<2/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(2/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -736,11 +885,17 @@ public void List_RenderSuggestion_DigitArgument() TokenClassification.ListPredictionSelected, ']' )), _.Alt_2, - CheckThat(() => AssertScreenIs(4, + CheckThat(() => AssertScreenIs(5, TokenClassification.Command, "echo", TokenClassification.None, ' ', TokenClassification.Parameter, "-bar", NextLine, + TokenClassification.ListPrediction, "<2/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<2/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(2/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -762,9 +917,13 @@ public void List_RenderSuggestion_DigitArgument() TokenClassification.None, "digit-argument: 2" )), _.UpArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'c', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " e", emphasisColors, 'c', @@ -794,16 +953,23 @@ public void List_RenderSuggestion_DigitArgument() [SkippableFact] public void List_RenderSuggestion_CtrlZ() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); SetHistory("echo -bar", "eca -zoo"); Test("e", Keys( - 'e', CheckThat(() => AssertScreenIs(3, + 'e', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -823,11 +989,17 @@ public void List_RenderSuggestion_CtrlZ() TokenClassification.None, ']' )), _.UpArrow, _.UpArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -848,9 +1020,13 @@ public void List_RenderSuggestion_CtrlZ() )), // No matter how many navigation operations were done, 'Ctrl+z' (undo) reverts back to the initial list view state. _.Ctrl_z, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -871,11 +1047,17 @@ public void List_RenderSuggestion_CtrlZ() )), // After undo, you can continue to navigate in the list. _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -906,16 +1088,23 @@ public void List_RenderSuggestion_CtrlZ() [SkippableFact] public void List_RenderSuggestion_Selection() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.ListView); SetHistory("echo -bar", "eca -zoo"); Test("eca -zoo", Keys( - 'e', CheckThat(() => AssertScreenIs(3, + 'e', CheckThat(() => AssertScreenIs(4, TokenClassification.Command, 'e', NextLine, + TokenClassification.ListPrediction, "<-/2>", + TokenClassification.None, new string(' ', listWidth - 17), // 17 is the length of '<-/2>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, 'e', @@ -936,11 +1125,17 @@ public void List_RenderSuggestion_Selection() )), _.DownArrow, CheckThat(() => AssertCursorLeftIs(8)), - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -962,11 +1157,17 @@ public void List_RenderSuggestion_Selection() // Moving cursor won't trigger a new prediction. _.LeftArrow, _.LeftArrow, CheckThat(() => AssertCursorLeftIs(6)), - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -987,11 +1188,17 @@ public void List_RenderSuggestion_Selection() )), _.Ctrl_LeftArrow, CheckThat(() => AssertCursorLeftIs(5)), - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -1012,12 +1219,18 @@ public void List_RenderSuggestion_Selection() )), // Text selection won't trigger a new prediction. _.Shift_LeftArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Selection, '-', TokenClassification.Parameter, "zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -1037,10 +1250,16 @@ public void List_RenderSuggestion_Selection() TokenClassification.None, ']' )), _.Ctrl_Shift_LeftArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Selection, "eca -", TokenClassification.Parameter, "zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -1071,8 +1290,11 @@ public void List_RenderSuggestion_Selection() [SkippableFact] public void List_HistorySource_NoAcceptanceCallback() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); // Using the 'History' source will not trigger 'acceptance' callbacks. @@ -1082,11 +1304,17 @@ public void List_HistorySource_NoAcceptanceCallback() SetHistory("echo -bar", "eca -zoo"); Test("eca -zooa", Keys( 'e', _.DownArrow, - CheckThat(() => AssertScreenIs(3, + CheckThat(() => AssertScreenIs(4, TokenClassification.Command, "eca", TokenClassification.None, ' ', TokenClassification.Parameter, "-zoo", NextLine, + TokenClassification.ListPrediction, "<1/2>", + TokenClassification.None, new string(' ', listWidth - 19), // 19 is the length of '<1/2>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, 'e', @@ -1128,8 +1356,11 @@ public void List_HistorySource_NoAcceptanceCallback() [SkippableFact] public void List_PluginSource_Acceptance() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); // Using the 'Plugin' source will make PSReadLine get prediction from the plugin only. @@ -1138,9 +1369,13 @@ public void List_PluginSource_Acceptance() SetHistory("echo -bar", "eca -zoo"); Test("SOME NEW TEX SOME TEXT AFTER", Keys( - "ec", CheckThat(() => AssertScreenIs(5, + "ec", CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "ec", NextLine, + TokenClassification.ListPrediction, "<-/3>", + TokenClassification.None, new string(' ', listWidth - 42), // 42 is the length of '<-/3>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "ec", @@ -1160,9 +1395,9 @@ public void List_PluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1173,10 +1408,16 @@ public void List_PluginSource_Acceptance() CheckThat(() => AssertDisplayedSuggestions(count: 2, predictorId_2, MiniSessionId, 1)), CheckThat(() => _mockedMethods.ClearPredictionFields()), _.DownArrow, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "SOME", TokenClassification.None, " TEXT BEFORE ec", NextLine, + TokenClassification.ListPrediction, "<1/3>", + TokenClassification.None, new string(' ', listWidth - 44), // 44 is the length of '<1/3>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "TestPredictor(1/2) ", + dimmedColors, "LongNamePredic…(1)>", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, " SOME TEXT BEFORE ", emphasisColors, "ec", @@ -1196,9 +1437,9 @@ public void List_PluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1207,9 +1448,15 @@ public void List_PluginSource_Acceptance() // `OnSuggestionDisplayed` should not be fired when navigating the list. CheckThat(() => Assert.Empty(_mockedMethods.displayedSuggestions)), _.Shift_Home, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Selection, "SOME TEXT BEFORE ec", NextLine, + TokenClassification.ListPrediction, "<1/3>", + TokenClassification.None, new string(' ', listWidth - 44), // 44 is the length of '<1/3>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "TestPredictor(1/2) ", + dimmedColors, "LongNamePredic…(1)>", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, " SOME TEXT BEFORE ", emphasisColors, "ec", @@ -1229,9 +1476,9 @@ public void List_PluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1240,9 +1487,13 @@ public void List_PluginSource_Acceptance() // `OnSuggestionDisplayed` should not be fired when selecting the input. CheckThat(() => Assert.Empty(_mockedMethods.displayedSuggestions)), "j", - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "j", NextLine, + TokenClassification.ListPrediction, "<-/3>", + TokenClassification.None, new string(' ', listWidth - 42), // 42 is the length of '<-/3>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "j", @@ -1262,9 +1513,9 @@ public void List_PluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1280,10 +1531,16 @@ public void List_PluginSource_Acceptance() _.DownArrow, _.DownArrow, _.DownArrow, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "SOME", TokenClassification.None, " NEW TEXT", NextLine, + TokenClassification.ListPrediction, "<3/3>", + TokenClassification.None, new string(' ', listWidth - 44), // 44 is the length of '<1/3>' plus ''. + dimmedColors, "', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "j", @@ -1303,9 +1560,9 @@ public void List_PluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, " SOME NEW TEXT", - TokenClassification.ListPredictionSelected, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.ListPredictionSelected, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.ListPredictionSelected, ']', // List view is done, no more list item following. NextLine, @@ -1314,10 +1571,14 @@ public void List_PluginSource_Acceptance() // `OnSuggestionDisplayed` should not be fired when navigating the input. CheckThat(() => Assert.Empty(_mockedMethods.displayedSuggestions)), _.Backspace, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "SOME", TokenClassification.None, " NEW TEX", NextLine, + TokenClassification.ListPrediction, "<-/3>", + TokenClassification.None, new string(' ', listWidth - 42), // 42 is the length of '<-/3>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "SOME NEW TEX", @@ -1339,9 +1600,9 @@ public void List_PluginSource_Acceptance() TokenClassification.None, ' ', emphasisColors, "SOME NEW TEX", TokenClassification.None, 'T', - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1356,10 +1617,16 @@ public void List_PluginSource_Acceptance() CheckThat(() => _mockedMethods.ClearPredictionFields()), _.UpArrow, _.UpArrow, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "SOME", TokenClassification.None, " NEW TEX SOME TEXT AFTER", NextLine, + TokenClassification.ListPrediction, "<2/3>", + TokenClassification.None, new string(' ', listWidth - 44), // 44 is the length of '<2/3>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "TestPredictor(2/2) ", + dimmedColors, "LongNamePredic…(1)>", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "SOME NEW TEX", @@ -1381,9 +1648,9 @@ public void List_PluginSource_Acceptance() TokenClassification.None, ' ', emphasisColors, "SOME NEW TEX", TokenClassification.None, 'T', - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1412,8 +1679,11 @@ public void List_PluginSource_Acceptance() [SkippableFact] public void List_HistoryAndPluginSource_Acceptance() { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); // Using the 'HistoryAndPlugin' source will make PSReadLine get prediction from both history and plugin. @@ -1422,9 +1692,13 @@ public void List_HistoryAndPluginSource_Acceptance() SetHistory("echo -bar", "java", "eca -zoo"); Test("SOME NEW TEX SOME TEXT AFTER", Keys( - "ec", CheckThat(() => AssertScreenIs(7, + "ec", CheckThat(() => AssertScreenIs(8, TokenClassification.Command, "ec", NextLine, + TokenClassification.ListPrediction, "<-/5>", + TokenClassification.None, new string(' ', listWidth - 36), // 36 is the length of '<-/5>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "ec", @@ -1462,9 +1736,9 @@ public void List_HistoryAndPluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1475,9 +1749,15 @@ public void List_HistoryAndPluginSource_Acceptance() CheckThat(() => AssertDisplayedSuggestions(count: 2, predictorId_2, MiniSessionId, 1)), CheckThat(() => _mockedMethods.ClearPredictionFields()), _.DownArrow, _.Shift_Home, - CheckThat(() => AssertScreenIs(7, + CheckThat(() => AssertScreenIs(8, TokenClassification.Selection, "eca -zoo", NextLine, + TokenClassification.ListPrediction, "<1/5>", + TokenClassification.None, new string(' ', listWidth - 38), // 38 is the length of '<1/5>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/2) ", + dimmedColors, "TestPredictor(2) …>", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, ' ', emphasisColors, "ec", @@ -1515,9 +1795,9 @@ public void List_HistoryAndPluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1525,9 +1805,13 @@ public void List_HistoryAndPluginSource_Acceptance() )), // `OnSuggestionDisplayed` should not be fired when navigating the list. CheckThat(() => Assert.Empty(_mockedMethods.displayedSuggestions)), - 'j', CheckThat(() => AssertScreenIs(6, + 'j', CheckThat(() => AssertScreenIs(7, TokenClassification.Command, "j", NextLine, + TokenClassification.ListPrediction, "<-/4>", + TokenClassification.None, new string(' ', listWidth - 36), // 36 is the length of '<-/4>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "j", @@ -1556,9 +1840,9 @@ public void List_HistoryAndPluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME NEW TEXT", - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1573,10 +1857,16 @@ public void List_HistoryAndPluginSource_Acceptance() CheckThat(() => Assert.Null(_mockedMethods.commandHistory)), CheckThat(() => _mockedMethods.ClearPredictionFields()), _.UpArrow, - CheckThat(() => AssertScreenIs(6, + CheckThat(() => AssertScreenIs(7, TokenClassification.Command, "SOME", TokenClassification.None, " NEW TEXT", NextLine, + TokenClassification.ListPrediction, "<4/4>", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '<4/4>' plus '<… TestPredictor(2) LongNamePredic…(1/1)>'. + dimmedColors, "<… TestPredictor(2) ", + TokenClassification.ListPrediction, "LongNamePredic…(1/1)", + dimmedColors, '>', + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, ' ', emphasisColors, "j", @@ -1605,9 +1895,9 @@ public void List_HistoryAndPluginSource_Acceptance() NextLine, TokenClassification.ListPrediction, '>', TokenClassification.ListPredictionSelected, " SOME NEW TEXT", - TokenClassification.ListPredictionSelected, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.ListPredictionSelected, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.ListPredictionSelected, ']', // List view is done, no more list item following. NextLine, @@ -1616,10 +1906,14 @@ public void List_HistoryAndPluginSource_Acceptance() // `OnSuggestionDisplayed` should not be fired when navigating the list. CheckThat(() => Assert.Empty(_mockedMethods.displayedSuggestions)), _.Backspace, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "SOME", TokenClassification.None, " NEW TEX", NextLine, + TokenClassification.ListPrediction, "<-/3>", + TokenClassification.None, new string(' ', listWidth - 42), // 42 is the length of '<-/3>' plus ''. + dimmedColors, "", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "SOME NEW TEX", @@ -1641,9 +1935,9 @@ public void List_HistoryAndPluginSource_Acceptance() TokenClassification.None, ' ', emphasisColors, "SOME NEW TEX", TokenClassification.None, 'T', - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1658,10 +1952,16 @@ public void List_HistoryAndPluginSource_Acceptance() CheckThat(() => _mockedMethods.ClearPredictionFields()), _.UpArrow, _.UpArrow, - CheckThat(() => AssertScreenIs(5, + CheckThat(() => AssertScreenIs(6, TokenClassification.Command, "SOME", TokenClassification.None, " NEW TEX SOME TEXT AFTER", NextLine, + TokenClassification.ListPrediction, "<2/3>", + TokenClassification.None, new string(' ', listWidth - 44), // 44 is the length of '<2/3>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "TestPredictor(2/2) ", + dimmedColors, "LongNamePredic…(1)>", + NextLine, TokenClassification.ListPrediction, '>', TokenClassification.None, " SOME TEXT BEFORE ", emphasisColors, "SOME NEW TEX", @@ -1683,9 +1983,9 @@ public void List_HistoryAndPluginSource_Acceptance() TokenClassification.None, ' ', emphasisColors, "SOME NEW TEX", TokenClassification.None, 'T', - TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePred...]' + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' TokenClassification.None, '[', - TokenClassification.ListPrediction, "LongNamePred...", + TokenClassification.ListPrediction, "LongNamePredic…", TokenClassification.None, ']', // List view is done, no more list item following. NextLine, @@ -1711,6 +2011,142 @@ public void List_HistoryAndPluginSource_Acceptance() Assert.Equal("SOME NEW TEX SOME TEXT AFTER", _mockedMethods.commandHistory[3]); } + [SkippableFact] + public void List_HistoryAndPluginSource_Deduplication() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + TestSetup(KeyMode.Cmd); + int listWidth = CheckWindowSize(); + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); + var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); + + // Using the 'HistoryAndPlugin' source will make PSReadLine get prediction from both history and plugin. + using var disp1 = SetPrediction(PredictionSource.HistoryAndPlugin, PredictionViewStyle.ListView); + _mockedMethods.ClearPredictionFields(); + + // The 1st result from 'predictorId_1' is the same as the 1st entry in history with case-insensitive comparison, + // which is the default comparison. So, that result will be filtered out due to the de-duplication logic. + SetHistory("some TEXT BEFORE de-dup", "de-dup -of"); + Test("de-dup", Keys( + "de-dup", CheckThat(() => AssertScreenIs(7, + TokenClassification.Command, "de-dup", + NextLine, + TokenClassification.ListPrediction, "<-/4>", + TokenClassification.None, new string(' ', listWidth - 36), // 36 is the length of '<-/4>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "de-dup", + TokenClassification.None, " -of", + TokenClassification.None, new string(' ', listWidth - 21), // 21 is the length of '> de-dup -of' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " some TEXT BEFORE ", + emphasisColors, "de-dup", + TokenClassification.None, new string(' ', listWidth - 34), // 34 is the length of '> SOME TEXT BEFORE de-dup' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "de-dup", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 39), // 35 is the length of '> de-dup SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + // `OnSuggestionDisplayed` should be fired for both predictors. + // For 'predictorId_1', the reported 'countOrIndex' from feedback is still 2 even though its 1st result was filtered out due to duplication. + CheckThat(() => AssertDisplayedSuggestions(count: 2, predictorId_1, MiniSessionId, 2)), + CheckThat(() => AssertDisplayedSuggestions(count: 2, predictorId_2, MiniSessionId, 1)), + CheckThat(() => _mockedMethods.ClearPredictionFields()), + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "de-dup", + NextLine, + NextLine)) + )); + + // Change the setting to be case sensitive, and check the list view content. + using var disp2 = SetHistorySearchCaseSensitive(caseSensitive: true); + _mockedMethods.ClearPredictionFields(); + + // The 1st result from 'predictorId_1' is not the same as the 2nd entry in history with the case-sensitive comparison. + // But the 2nd result from 'predictorId_1' is the same as teh 1st entry in history with the case-sensitive comparison, + // so, that result will be filtered out due to the de-duplication logic. + SetHistory("de-dup SOME TEXT AFTER", "some TEXT BEFORE de-dup"); + Test("de-dup", Keys( + "de-dup", CheckThat(() => AssertScreenIs(7, + TokenClassification.Command, "de-dup", + NextLine, + TokenClassification.ListPrediction, "<-/4>", + TokenClassification.None, new string(' ', listWidth - 36), // 36 is the length of '<-/4>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "de-dup", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 33), // 33 is the length of '> de-dup SOME TEXT AFTER' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " some TEXT BEFORE ", + emphasisColors, "de-dup", + TokenClassification.None, new string(' ', listWidth - 34), // 34 is the length of '> some TEXT BEFORE de-dup' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "de-dup", + TokenClassification.None, new string(' ', listWidth - 40), // 40 is the length of '> SOME TEXT BEFORE de-dup' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + // `OnSuggestionDisplayed` should be fired for both predictors. + // For 'predictorId_1', the reported 'countOrIndex' from feedback is still 2 even though its 2nd result was filtered out due to duplication. + CheckThat(() => AssertDisplayedSuggestions(count: 2, predictorId_1, MiniSessionId, 2)), + CheckThat(() => AssertDisplayedSuggestions(count: 2, predictorId_2, MiniSessionId, 1)), + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "de-dup", + NextLine, + NextLine)) + )); + } + [SkippableFact] public void List_NoneSource_ExecutionStatus() { diff --git a/test/ListScrollableViewTest.cs b/test/ListScrollableViewTest.cs new file mode 100644 index 000000000..3452c4625 --- /dev/null +++ b/test/ListScrollableViewTest.cs @@ -0,0 +1,914 @@ +ο»Ώusing System; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + [SkippableFact] + public void List_MetaLine_And_Paging_Navigation() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + int listWidth = 100; + TestSetup(new TestConsole(keyboardLayout: _, width: listWidth, height: 15), KeyMode.Cmd); + + // The font effect sequences of the dimmed color used in list view metadata line + // are ignored in the mock console, so only the white color will be left. + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); + var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); + + // Using the 'HistoryAndPlugin' source will make PSReadLine get prediction from both history and plugin. + using var disp = SetPrediction(PredictionSource.HistoryAndPlugin, PredictionViewStyle.ListView); + _mockedMethods.ClearPredictionFields(); + + SetHistory("metadata-line -zoo"); + Test("SOME TEXT BEFORE metadata-line", Keys( + "metadata-line", CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "metadata-line", + NextLine, + TokenClassification.ListPrediction, "<-/5>", + TokenClassification.None, new string(' ', listWidth - 55), // 55 is the length of '<-/5>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, _.PageDown, + CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.ListPrediction, "<5/5>", + TokenClassification.None, new string(' ', listWidth - 58), // 58 is the length of '<5/5>' plus '<… TestPredictor(2) LongNamePredic…(1) Metadata(1/1)>'. + dimmedColors, "<… TestPredictor(2) LongNamePredic…(1) ", + TokenClassification.ListPrediction, "Metadata(1/1)", + dimmedColors, '>', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.ListPredictionSelected, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.PageUp, CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "metadata-line", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo", + NextLine, + TokenClassification.ListPrediction, "<1/5>", + TokenClassification.None, new string(' ', listWidth - 57), // 57 is the length of '<1/5>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/1) ", + dimmedColors, "TestPredictor(2) LongNamePredic…(1) …>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, " -zoo", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageDown, CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "SOME", + TokenClassification.None, " TEXT BEFORE metadata-line", + NextLine, + TokenClassification.ListPrediction, "<2/5>", + TokenClassification.None, new string(' ', listWidth - 57), // 57 is the length of '<1/5>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageDown, CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.ListPrediction, "<4/5>", + TokenClassification.None, new string(' ', listWidth - 58), // 58 is the length of '<4/5>' plus '<… TestPredictor(2) LongNamePredic…(1/1) Metadata(1)>'. + dimmedColors, "<… TestPredictor(2) ", + TokenClassification.ListPrediction, "LongNamePredic…(1/1) ", + dimmedColors, "Metadata(1)>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageDown, CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.ListPrediction, "<5/5>", + TokenClassification.None, new string(' ', listWidth - 58), // 58 is the length of '<5/5>' plus '<… TestPredictor(2) LongNamePredic…(1) Metadata(1/1)>'. + dimmedColors, "<… TestPredictor(2) LongNamePredic…(1) ", + TokenClassification.ListPrediction, "Metadata(1/1)", + dimmedColors, '>', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.ListPredictionSelected, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageDown, CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "metadata-line", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo", + NextLine, + TokenClassification.ListPrediction, "<1/5>", + TokenClassification.None, new string(' ', listWidth - 57), // 57 is the length of '<1/5>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/1) ", + dimmedColors, "TestPredictor(2) LongNamePredic…(1) …>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, " -zoo", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageUp, CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.ListPrediction, "<5/5>", + TokenClassification.None, new string(' ', listWidth - 58), // 58 is the length of '<5/5>' plus '<… TestPredictor(2) LongNamePredic…(1) Metadata(1/1)>'. + dimmedColors, "<… TestPredictor(2) LongNamePredic…(1) ", + TokenClassification.ListPrediction, "Metadata(1/1)", + dimmedColors, '>', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.ListPredictionSelected, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageUp, _.Ctrl_PageUp, + CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "SOME", + TokenClassification.None, " TEXT BEFORE metadata-line", + NextLine, + TokenClassification.ListPrediction, "<2/5>", + TokenClassification.None, new string(' ', listWidth - 57), // 57 is the length of '<1/5>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "SOME", + TokenClassification.None, " TEXT BEFORE metadata-line", + NextLine, + NextLine)) + )); + } + + [SkippableFact] + public void ListView_AdapteTo_ConsoleSize() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + // Console size is very small (h: 6, w: 50), and thus the list view will adjust to use 3-line height, + // and the metadata line will be reduced to only show the (index/total) info. + int listWidth = 50; + TestSetup(new TestConsole(keyboardLayout: _, width: listWidth, height: 6), KeyMode.Cmd); + var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); + + // Using the 'HistoryAndPlugin' source will make PSReadLine get prediction from both history and plugin. + using var disp = SetPrediction(PredictionSource.HistoryAndPlugin, PredictionViewStyle.ListView); + _mockedMethods.ClearPredictionFields(); + + SetHistory("metadata-line -zoo"); + Test("metadata-line -zoo", Keys( + "metadata-line", CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "metadata-line", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<-/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.UpArrow, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<5/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.ListPredictionSelected, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.UpArrow, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<4/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.Ctrl_PageUp, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "SOME", + TokenClassification.None, " TEXT BEFORE metadata-line", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<2/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.PageUp, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "metadata-line", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<1/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, " -zoo", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.PageDown, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<3/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " -zoo", + TokenClassification.None, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, " SOME TEXT AFTER", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.ListPredictionSelected, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.PageDown, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "SOME", + TokenClassification.None, " NEW TEXT", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<5/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, " SOME NEW TEXT", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.ListPredictionSelected, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "metadata-line", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<-/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 32), // 32 is the length of '> SOME NEW TEXT' plus '[LongNamePredic…]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "LongNamePredic…", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME NEW TEXT", + TokenClassification.None, new string(' ', listWidth - 25), // 25 is the length of '> SOME NEW TEXT' plus '[Metadata]' + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Metadata", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "metadata-line", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo", + NextLine, + TokenClassification.None, " ", + TokenClassification.ListPrediction, "<1/5>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "metadata-line", + TokenClassification.ListPredictionSelected, " -zoo", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 29), // 29 is the length of '> metadata-line -zoo' plus '[History]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, " SOME TEXT BEFORE ", + emphasisColors, "metadata-line", + TokenClassification.None, new string(' ', listWidth - 47), // 47 is the length of '> SOME TEXT BEFORE metadata-line' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "metadata-line", + TokenClassification.None, " SOME TEXT AFTER", + TokenClassification.None, new string(' ', listWidth - 46), // 46 is the length of '> metadata-line SOME TEXT AFTER' plus '[TestPredictor]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "TestPredictor", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "metadata-line", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-zoo", + NextLine, + NextLine)) + )); + } + + [SkippableFact] + public void ListView_TermSize_Warning() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + // Console size is very small (h: 6, w: 50), and thus the list view will adjust to use 3-line height, + // and the metadata line will be reduced to only show the (index/total) info. + int listWidth = 40; + TestSetup(new TestConsole(keyboardLayout: _, width: listWidth, height: 4), KeyMode.Cmd); + using var disp = SetPrediction(PredictionSource.History, PredictionViewStyle.InlineView); + + Test("git", Keys( + _.F2, // Switch to the list view, then test the warning message. + 'g', CheckThat(() => AssertScreenIs(4, + TokenClassification.Command, "g", + NextLine, + TokenClassification.ListPrediction, "! terminal size too small to show the li", + NextLine, + TokenClassification.ListPrediction, "st view", + // List view is done, no more list item following. + NextLine, + NextLine + )), + 'i', CheckThat(() => AssertScreenIs(4, + TokenClassification.Command, "gi", + NextLine, + TokenClassification.ListPrediction, "! terminal size too small to show the li", + NextLine, + TokenClassification.ListPrediction, "st view", + // List view is done, no more list item following. + NextLine, + NextLine + )), + + // Escape should clear the warning as well. + _.Escape, CheckThat(() => AssertScreenIs(3, + NextLine, + NextLine, + NextLine + )), + "git", CheckThat(() => AssertScreenIs(4, + TokenClassification.Command, "git", + NextLine, + TokenClassification.ListPrediction, "! terminal size too small to show the li", + NextLine, + TokenClassification.ListPrediction, "st view", + // List view is done, no more list item following. + NextLine, + NextLine + )), + + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(3, + TokenClassification.Command, "git", + NextLine, + NextLine, + NextLine)) + )); + } + } +} diff --git a/test/ListViewTooltipTest.cs b/test/ListViewTooltipTest.cs new file mode 100644 index 000000000..79a6ba592 --- /dev/null +++ b/test/ListViewTooltipTest.cs @@ -0,0 +1,490 @@ +using System; +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + [SkippableFact] + public void List_Item_Tooltip_4_Lines() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + // Set the terminal height to 22 and width to 60, so the metadata line will be fully rendered + // and maximum 4 lines can be used for tooltip for a selected list item. + int listWidth = 60; + TestSetup(new TestConsole(keyboardLayout: _, width: listWidth, height: 22), KeyMode.Cmd); + + // The font effect sequences of 'dim' and 'italic' used in list view metadata line + // are ignored in the mock console, so only the white color will be left. + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); + var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); + using var disp = SetPrediction(PredictionSource.HistoryAndPlugin, PredictionViewStyle.ListView); + _mockedMethods.ClearPredictionFields(); + + SetHistory("tooltip -history"); + Test("tooltip NO2", Keys( + "tooltip", CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "tooltip", + NextLine, + TokenClassification.ListPrediction, "<-/3>", + TokenClassification.None, new string(' ', listWidth - 28), // 28 is the length of '<-/3>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO1", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO2", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, + CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "tooltip", + TokenClassification.None, ' ', + TokenClassification.Parameter, "-history", + NextLine, + TokenClassification.ListPrediction, "<1/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<1/3>' plus ''. + dimmedColors, '<', + TokenClassification.ListPrediction, "History(1/1) ", + dimmedColors, "Tooltip(2)>", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " -history", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.ListPredictionSelected, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO1", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO2", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, + CheckThat(() => AssertScreenIs(10, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO1", + NextLine, + TokenClassification.ListPrediction, "<2/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<2/3>' plus ''. + dimmedColors, "', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " NO1", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.ListPredictionSelected, ']', + NextLine, + dimmedColors, " >> Hello", NextLine, + dimmedColors, " Binary", NextLine, + dimmedColors, " World", NextLine, + dimmedColors, " PowerShell is a task automation an… ", + TokenClassification.ListPrediction, "( to view all)", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO2", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, + CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO2", + NextLine, + TokenClassification.ListPrediction, "<3/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<3/3>' plus ''. + dimmedColors, "', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO1", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " NO2", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.ListPredictionSelected, ']', + NextLine, + dimmedColors, " >> Hello", NextLine, + dimmedColors, " World", + // List view is done, no more list item following. + NextLine, + NextLine + )), + + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO2", + NextLine, + NextLine)) + )); + } + + [SkippableFact] + public void List_Item_Tooltip_2_Lines() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + // Set the terminal height to 15 and width to 60, so the metadata line will be fully rendered + // and maximum 2 lines can be used for tooltip for a selected list item. + int listWidth = 60; + TestSetup(new TestConsole(keyboardLayout: _, width: listWidth, height: 15), KeyMode.Cmd); + + // The font effect sequences of 'dim' and 'italic' used in list view metadata line + // are ignored in the mock console, so only the white color will be left. + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); + var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); + using var disp = SetPrediction(PredictionSource.HistoryAndPlugin, PredictionViewStyle.ListView); + _mockedMethods.ClearPredictionFields(); + + SetHistory("tooltip -history"); + Test("tooltip NO2", Keys( + "tooltip", CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "tooltip", + NextLine, + TokenClassification.ListPrediction, "<-/3>", + TokenClassification.None, new string(' ', listWidth - 28), // 28 is the length of '<-/3>' plus ''. + dimmedColors, "", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO1", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO2", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.DownArrow, _.DownArrow, + CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO1", + NextLine, + TokenClassification.ListPrediction, "<2/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<2/3>' plus ''. + dimmedColors, "', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " NO1", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.ListPredictionSelected, ']', + NextLine, + dimmedColors, " >> Hello", NextLine, + dimmedColors, " Binary … ", + TokenClassification.ListPrediction, "( to view all)", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO2", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine, + NextLine + )), + _.F4, + CheckThat(() => Assert.Equal( + "Hello\nBinary\nWorld\nPowerShell is a task automation and configuration management program from Microsoft", + _mockedMethods.helpContentRendered)), + _.DownArrow, + CheckThat(() => AssertScreenIs(8, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO2", + NextLine, + TokenClassification.ListPrediction, "<3/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<3/3>' plus ''. + dimmedColors, "', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO1", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " NO2", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.ListPredictionSelected, ']', + NextLine, + dimmedColors, " >> Hello", NextLine, + dimmedColors, " World", + // List view is done, no more list item following. + NextLine, + NextLine + )), + + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO2", + NextLine, + NextLine)) + )); + } + + [SkippableFact] + public void List_Item_Tooltip_1_Line() + { + Skip.If(ScreenReaderModeEnabled, "List view is not supported in screen reader mode."); + + // Set the terminal height to 6 and width to 60, so the metadata line will be fully rendered + // and maximum 2 lines can be used for tooltip for a selected list item. + int listWidth = 60; + TestSetup(new TestConsole(keyboardLayout: _, width: listWidth, height: 6), KeyMode.Cmd); + + // The font effect sequences of 'dim' and 'italic' used in list view metadata line + // are ignored in the mock console, so only the white color will be left. + var dimmedColors = Tuple.Create(ConsoleColor.White, _console.BackgroundColor); + var emphasisColors = Tuple.Create(PSConsoleReadLineOptions.DefaultEmphasisColor, _console.BackgroundColor); + using var disp = SetPrediction(PredictionSource.HistoryAndPlugin, PredictionViewStyle.ListView); + _mockedMethods.ClearPredictionFields(); + + SetHistory("tooltip -history"); + Test("tooltip NO2", Keys( + "tooltip", _.DownArrow, _.DownArrow, + CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO1", + NextLine, + TokenClassification.ListPrediction, "<2/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<2/3>' plus ''. + dimmedColors, "', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " NO1", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.ListPredictionSelected, ']', + NextLine, + dimmedColors, " >> Hello … ", + TokenClassification.ListPrediction, "( to view all)", + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO2", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + // List view is done, no more list item following. + NextLine + )), + _.F4, + CheckThat(() => Assert.Equal( + "Hello\nBinary\nWorld\nPowerShell is a task automation and configuration management program from Microsoft", + _mockedMethods.helpContentRendered)), + _.DownArrow, + CheckThat(() => AssertScreenIs(6, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO2", + NextLine, + TokenClassification.ListPrediction, "<3/3>", + TokenClassification.None, new string(' ', listWidth - 30), // 30 is the length of '<3/3>' plus ''. + dimmedColors, "', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " -history", + TokenClassification.None, new string(' ', listWidth - 27), // 27 is the length of '> tooltip -history' plus '[History]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "History", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.None, ' ', + emphasisColors, "tooltip", + TokenClassification.None, " NO1", + TokenClassification.None, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO1' plus '[Tooltip]'. + TokenClassification.None, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.None, ']', + NextLine, + TokenClassification.ListPrediction, '>', + TokenClassification.ListPredictionSelected, ' ', + emphasisColors, "tooltip", + TokenClassification.ListPredictionSelected, " NO2", + TokenClassification.ListPredictionSelected, new string(' ', listWidth - 22), // 22 is the length of '> tooltip NO2' plus '[Tooltip]'. + TokenClassification.ListPredictionSelected, '[', + TokenClassification.ListPrediction, "Tooltip", + TokenClassification.ListPredictionSelected, ']', + NextLine, + dimmedColors, " >> Hello … ", + TokenClassification.ListPrediction, "( to view all)", + // List view is done, no more list item following. + NextLine + )), + _.F4, + CheckThat(() => Assert.Equal( + "Hello\nWorld", + _mockedMethods.helpContentRendered)), + + // Once accepted, the list should be cleared. + _.Enter, CheckThat(() => AssertScreenIs(2, + TokenClassification.Command, "tooltip", + TokenClassification.None, " NO2", + NextLine, + NextLine)) + )); + } + } +} diff --git a/test/MockConsole.cs b/test/MockConsole.cs index 372a6fff0..990698671 100644 --- a/test/MockConsole.cs +++ b/test/MockConsole.cs @@ -102,7 +102,7 @@ protected TestConsole(int width, int height, bool mimicScrolling) _bufferWidth = _windowWidth = width; _bufferHeight = _windowHeight = height; - // Use a big enough buffer when we are mimicing scrolling. + // Use a big enough buffer when we are mimicking scrolling. int bufferSize = mimicScrolling ? BufferWidth * 1000 : BufferWidth * BufferHeight; buffer = new CHAR_INFO[bufferSize]; ClearBuffer(); @@ -235,6 +235,13 @@ public virtual void Write(string s) var escapeSequence = s.Substring(i + 2, len); foreach (var subsequence in escapeSequence.Split(';')) { + if (subsequence is "1" or "2" or "3") + { + // Ignore the font effect sequence: 1 - bold; 2 - dimmed color; 3 - italics + // They are used in the metadata line of the list view. + continue; + } + EscapeSequenceActions[subsequence](this); } i = endSequence; @@ -293,6 +300,17 @@ public virtual void BlankRestOfLine() buffer[writePos + i].ForegroundColor = ForegroundColor; } } + + public virtual void BlankRestOfBuffer() + { + var writePos = CursorTop * BufferWidth + CursorLeft; + for (; writePos < buffer.Length; writePos++) + { + buffer[writePos].UnicodeChar = ' '; + buffer[writePos].BackgroundColor = BackgroundColor; + buffer[writePos].ForegroundColor = ForegroundColor; + } + } public virtual void Clear() { @@ -327,6 +345,7 @@ private static void ToggleNegative(TestConsole c, bool b) c.BackgroundColor = (ConsoleColor)((int)c.BackgroundColor ^ 7); c._negative = b; } + protected static readonly Dictionary> EscapeSequenceActions = new() { {"7", c => ToggleNegative(c, true) }, @@ -369,7 +388,8 @@ private static void ToggleNegative(TestConsole c, bool b) c.ForegroundColor = DefaultForeground; c.BackgroundColor = DefaultBackground; }}, - {"2J", c => c.SetCursorPosition(0, 0) } + { "0J", c => c.BlankRestOfBuffer() }, + { "2J", c => c.SetCursorPosition(0, 0) }, }; } @@ -444,6 +464,13 @@ public override void Write(string s) var escapeSequence = s.Substring(i + 2, len); foreach (var subsequence in escapeSequence.Split(';')) { + if (subsequence is "1" or "2" or "3") + { + // Ignore the font effect sequence: 1 - bold; 2 - dimmed color; 3 - italics + // They are used in the metadata line of the list view. + continue; + } + EscapeSequenceActions[subsequence](this); } i = endSequence; @@ -506,6 +533,17 @@ public override void BlankRestOfLine() } } + public override void BlankRestOfBuffer() + { + var writePos = (_offset + CursorTop) * BufferWidth + CursorLeft; + for (; writePos < buffer.Length; writePos++) + { + buffer[writePos].UnicodeChar = ' '; + buffer[writePos].BackgroundColor = BackgroundColor; + buffer[writePos].ForegroundColor = ForegroundColor; + } + } + public override void Clear() { _offset = 0; diff --git a/test/MovementTest.VI.Multiline.cs b/test/MovementTest.VI.Multiline.cs index 68e49ba62..0754efd39 100644 --- a/test/MovementTest.VI.Multiline.cs +++ b/test/MovementTest.VI.Multiline.cs @@ -12,8 +12,6 @@ public void ViMoveToLine_DesiredColumn() const string buffer = "\"\n12345\n1234\n123\n12\n1\n\""; - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test(buffer, Keys( _.DQuote, _.Enter, "12345", _.Enter, @@ -25,22 +23,22 @@ public void ViMoveToLine_DesiredColumn() _.Escape, // move to second line at column 4 - "ggj3l", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)), + "ggj3l", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)), // moving down on shorter lines will position the cursor at the end of each logical line - _.j, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)), - _.j, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 2)), + _.j, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)), + _.j, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 2)), // moving back up will position the cursor at the end of shorter lines or at the desired column number - _.k, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)), - _.k, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)), + _.k, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)), + _.k, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)), // move at end of line (column 5) - _.Dollar, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 4)), + _.Dollar, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 4)), // moving down on shorter lines will position the cursor at the end of each logical line - _.j, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)), - _.j, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 2)), + _.j, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)), + _.j, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 2)), // moving back up will position the cursor at the end of each logical line - _.k, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 3)), - _.k, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 4)) + _.k, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 3)), + _.k, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 4)) )); } @@ -52,19 +50,17 @@ public void ViBackwardChar() const string buffer = "\"\nline2\nline3\n\""; - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test(buffer, Keys( _.DQuote, _.Enter, "line2", _.Enter, "line3", _.Enter, _.DQuote, _.Escape, - _.k, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + _.k, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), // move left - _.h, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), - _.l, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 1)), - "2h", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + _.h, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), + _.l, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 1)), + "2h", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); } @@ -75,18 +71,16 @@ public void ViForwardChar() const string buffer = "\"\nline2\nline3\n\""; - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test(buffer, Keys( _.DQuote, _.Enter, "line2", _.Enter, "line3", _.Enter, _.DQuote, _.Escape, - _.k, _.k, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + _.k, _.k, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), // move right - _.l, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 1)), - "10l", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 4)) + _.l, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 1)), + "10l", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 4)) )); } @@ -97,8 +91,6 @@ public void ViMoveToFirstLogicalLineThenJumpToLastLogicalLine() const string buffer = "\"Multiline buffer\n containing an empty line\n\nand text aligned on the left\n\""; - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test(buffer, Keys( _.DQuote, "Multiline buffer", _.Enter, " containing an empty line", _.Enter, @@ -107,7 +99,7 @@ public void ViMoveToFirstLogicalLineThenJumpToLastLogicalLine() _.DQuote, _.Escape, CheckThat(() => AssertCursorTopIs(4)), "gg", CheckThat(() => AssertCursorLeftTopIs(0, 0)), - 'G', CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength, 4)) + 'G', CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 4)) )); } @@ -148,17 +140,15 @@ public void ViMoveToFirstNonBlankOfLogicalLineThenJumpToEndOfLogicalLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - const string buffer = "\"\n line\""; Test(buffer, Keys( - _.DQuote, _.Enter, " line", _.DQuote, _.Escape, CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 6)), - _.Underbar, CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 2, 1)), - _.Dollar, CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 6, 1)), + _.DQuote, _.Enter, " line", _.DQuote, _.Escape, CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 6)), + _.Underbar, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 2, 1)), + _.Dollar, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 6, 1)), // also works forward - '0', CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength, 1)), - _.Underbar, CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 2, 1)) + '0', CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.Underbar, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 2, 1)) )); } @@ -167,14 +157,12 @@ public void ViMoveToFirstNonBlankOfLogicalLine_NoOp_OnEmptyLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - const string buffer = "\"\n\n\""; Test(buffer, Keys( _.DQuote, _.Enter, _.Enter, _.DQuote, _.Escape, _.k, - CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 0, 1)), - _.Underbar, CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 0, 1)) + CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 0, 1)), + _.Underbar, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 0, 1)) )); } @@ -193,14 +181,12 @@ public void ViMoveToEndOfLine_NoOp_OnEmptyLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - const string buffer = "\"\n\n\""; Test(buffer, Keys( _.DQuote, _.Enter, _.Enter, _.DQuote, _.Escape, _.k, - CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 0, 1)), - _.Dollar, CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 0, 1)) + CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 0, 1)), + _.Dollar, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 0, 1)) )); } diff --git a/test/MovementTest.VI.cs b/test/MovementTest.VI.cs index 3c4573ebe..27dfe7956 100644 --- a/test/MovementTest.VI.cs +++ b/test/MovementTest.VI.cs @@ -370,7 +370,7 @@ public void ViGlobMovement_EmptyBuffer_Defect1195() TestSetup(KeyMode.Vi); TestMustDing("", Keys( - _.Escape, "W" + _.Escape, "W" )); } @@ -423,6 +423,11 @@ public void ViCursorMovement() [SkippableFact] public void ViGotoBrace() { + // NOTE: When the input has unmatched braces, in order to avoid an + // exception caused by AcceptLineImpl waiting for incomplete input, + // the test needs to end with the Vi command "ddi" and assert that + // the result is an empty string. + TestSetup(KeyMode.Vi); Test("0[2(4{6]8)a}c", Keys( @@ -450,10 +455,60 @@ public void ViGotoBrace() CheckThat(() => AssertCursorLeftIs(4)), _.Percent, CheckThat(() => AssertCursorLeftIs(4)), - "ddi" + "ddi" // Unmatched brace )); } + // Tests when the cursor is not on any paren + foreach (var (opening, closing) in new[] { ('(', ')'), ('{', '}'), ('[', ']') }) + { + // Closing paren with backward match + string input1 = $"0{opening}2{opening}4foo{closing}"; + Test("", Keys( + input1, + CheckThat(() => AssertCursorLeftIs(9)), + _.Escape, CheckThat(() => AssertCursorLeftIs(8)), + "0ff", CheckThat(() => AssertCursorLeftIs(5)), + _.Percent, CheckThat(() => AssertCursorLeftIs(3)), + _.Percent, CheckThat(() => AssertCursorLeftIs(8)), + "ddi" // Unmatched closing brace + )); + + // Closing paren without backward match + string input2 = $"0]2)4foo{closing}"; + TestMustDing(input2, Keys( + input2, + CheckThat(() => AssertCursorLeftIs(9)), + _.Escape, CheckThat(() => AssertCursorLeftIs(8)), + "0ff", CheckThat(() => AssertCursorLeftIs(5)), + _.Percent, CheckThat(() => AssertCursorLeftIs(5)), // stay still + _.Percent, CheckThat(() => AssertCursorLeftIs(5)) + )); + + // Opening paren with forward match + string input3 = $"0{opening}2foo6{closing}"; + Test(input3, Keys( + input3, + CheckThat(() => AssertCursorLeftIs(8)), + _.Escape, CheckThat(() => AssertCursorLeftIs(7)), + "0ff", CheckThat(() => AssertCursorLeftIs(3)), + _.Percent, CheckThat(() => AssertCursorLeftIs(1)), + _.Percent, CheckThat(() => AssertCursorLeftIs(7)) + )); + + // Opening paren without forward match + string input4 = $"0)2]4foo{opening}("; + TestMustDing("", Keys( + input4, + CheckThat(() => AssertCursorLeftIs(10)), + _.Escape, CheckThat(() => AssertCursorLeftIs(9)), + "0ff", CheckThat(() => AssertCursorLeftIs(5)), + _.Percent, CheckThat(() => AssertCursorLeftIs(5)), // stay still + _.Percent, CheckThat(() => AssertCursorLeftIs(5)), + "ddi" // Unmatched brace + )); + } + // <%> with empty text buffer should work fine. Test("", Keys( _.Escape, _.Percent, diff --git a/test/MovementTest.cs b/test/MovementTest.cs index 0c687cd0a..4bf4b99a0 100644 --- a/test/MovementTest.cs +++ b/test/MovementTest.cs @@ -35,7 +35,6 @@ public void MultilineCursorMovement_WithWrappedLines() { TestSetup(KeyMode.Cmd); - int continutationPromptLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; string line_0 = "4444"; string line_1 = "33"; string line_2 = "666666"; @@ -43,8 +42,8 @@ public void MultilineCursorMovement_WithWrappedLines() int wrappedLength_1 = 9; int wrappedLength_2 = 2; - string wrappedLine_1 = new string('8', _console.BufferWidth - continutationPromptLength + wrappedLength_1); // Take 2 physical lines - string wrappedLine_2 = new string('6', _console.BufferWidth - continutationPromptLength + wrappedLength_2); // Take 2 physical lines + string wrappedLine_1 = new string('8', _console.BufferWidth - ContinuationPromptLength + wrappedLength_1); // Take 2 physical lines + string wrappedLine_2 = new string('6', _console.BufferWidth - ContinuationPromptLength + wrappedLength_2); // Take 2 physical lines Test("", Keys( "", _.Shift_Enter, // physical line 0 @@ -57,45 +56,45 @@ public void MultilineCursorMovement_WithWrappedLines() // Starting at the end of the last line. // Verify that UpArrow goes to the end of the previous logical line. - CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_3.Length, 8)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_3.Length, 8)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_3.Length, 8)), + CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_3.Length, 8)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_3.Length, 8)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_3.Length, 8)), // Press Up/Down/Up _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_2, 7)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_3.Length, 8)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_3.Length, 8)), _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_2, 7)), // Press Up/Down/Up _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_1, 5)), _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_2, 7)), _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_1, 5)), // Press Up/Down/Up - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_2.Length, 3)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_2.Length, 3)), _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_1, 5)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_2.Length, 3)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_2.Length, 3)), // Press Up/Up - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_1.Length, 2)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length, 1)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_1.Length, 2)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length, 1)), // Move to left for 1 character, so the cursor now is not at the end of line. // Verify that DownArrow/UpArrow goes to the previous logical line at the same column. - _.LeftArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 1)), + _.LeftArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 1)), // Press Down all the way to the end - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_1.Length, 2)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 3)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 4)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 5)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 6)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_1.Length, 2)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 3)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 4)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 5)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 6)), _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_2, 7)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_3.Length, 8)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_3.Length, 8)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_3.Length, 8)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_3.Length, 8)), // Press Up all the way to the physical line 1 _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(wrappedLength_2, 7)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 6)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 5)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 4)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 3)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_1.Length, 2)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength + line_0.Length - 1, 1)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 6)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 5)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 4)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 3)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_1.Length, 2)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + line_0.Length - 1, 1)), // Clear the input, we were just testing movement _.Escape @@ -107,7 +106,6 @@ public void MultilineCursorMovement() { TestSetup(KeyMode.Cmd); - var continutationPromptLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; Test("", Keys( "4444", _.Shift_Enter, "666666", _.Shift_Enter, @@ -118,39 +116,39 @@ public void MultilineCursorMovement() // Starting at the end of the next to last line (because it's not blank) // Verify that Home first goes to the start of the line, then the start of the input. _.LeftArrow, - _.Home, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 4)), + _.Home, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 4)), _.Home, CheckThat(() => AssertCursorLeftTopIs(0, 0)), // Now (because we're at the start), verify first End goes to end of the line // and the second End goes to the end of the input. _.End, CheckThat(() => AssertCursorLeftTopIs(4, 0)), - _.End, CheckThat(() => AssertCursorLeftTopIs(0 + continutationPromptLength, 5)), + _.End, CheckThat(() => AssertCursorLeftTopIs(0 + ContinuationPromptLength, 5)), _.Home, _.Home, - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 2)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 3)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 4)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 5)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 2)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 3)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 4)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 5)), _.LeftArrow, _.Home, - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 3)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 2)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 0)), // was (4,0), but seems wrong + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 3)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 2)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 0)), // was (4,0), but seems wrong // Make sure that movement between lines stays at the end of a line if it starts // at the end of a line _.End, _.End, - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(4 + continutationPromptLength, 4)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 3)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(8 + continutationPromptLength, 2)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 1)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(4 + ContinuationPromptLength, 4)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 3)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(8 + ContinuationPromptLength, 2)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 1)), _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(4, 0)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 1)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(8 + continutationPromptLength, 2)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 3)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(4 + continutationPromptLength, 4)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(0 + continutationPromptLength, 5)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 1)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(8 + ContinuationPromptLength, 2)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 3)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(4 + ContinuationPromptLength, 4)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(0 + ContinuationPromptLength, 5)), _.Escape, _.Shift_Enter, @@ -160,22 +158,22 @@ public void MultilineCursorMovement() "55555", _.Shift_Enter, "88888888", _.LeftArrow, _.LeftArrow, - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(5 + continutationPromptLength, 4)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(2 + continutationPromptLength, 3)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(5 + continutationPromptLength, 2)), - _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 1)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(5 + ContinuationPromptLength, 4)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(2 + ContinuationPromptLength, 3)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(5 + ContinuationPromptLength, 2)), + _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 1)), _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(0, 0)), _.UpArrow, CheckThat(() => AssertCursorLeftTopIs(0, 0)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 1)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(5 + continutationPromptLength, 2)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(2 + continutationPromptLength, 3)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(5 + continutationPromptLength, 4)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + continutationPromptLength, 5)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 1)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(5 + ContinuationPromptLength, 2)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(2 + ContinuationPromptLength, 3)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(5 + ContinuationPromptLength, 4)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(6 + ContinuationPromptLength, 5)), // Using the input previously entered, check for correct cursor movements when first line is blank _.Home, _.Home, CheckThat(() => AssertCursorLeftTopIs(0, 0)), - _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(8 + continutationPromptLength, 1)), - _.Home, CheckThat(() => AssertCursorLeftTopIs(continutationPromptLength, 1)), + _.DownArrow, CheckThat(() => AssertCursorLeftTopIs(8 + ContinuationPromptLength, 1)), + _.Home, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), _.Home, CheckThat(() => AssertCursorLeftTopIs(0,0)), // Clear the input, we were just testing movement diff --git a/test/OptionsTest.cs b/test/OptionsTest.cs index 67e36538c..5f67de71c 100644 --- a/test/OptionsTest.cs +++ b/test/OptionsTest.cs @@ -11,6 +11,8 @@ public partial class ReadLine [SkippableFact] public void ContinuationPrompt() { + Skip.If(ScreenReaderModeEnabled, "Continuation prompt is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); Test("", Keys( @@ -55,6 +57,20 @@ public void ContinuationPrompt() )); } + [SkippableFact] + public void ContinuationPromptForScreenReader() + { + Skip.IfNot(ScreenReaderModeEnabled); + TestSetup(KeyMode.Cmd); + + Test("", Keys( + "{\n}", + CheckThat(() => AssertScreenIs(2, TokenClassification.None, '{', NextLine, '}' )), + _.Ctrl_c, + InputAcceptedNow + )); + } + [SkippableFact] public void GetKeyHandlers() { diff --git a/test/PSReadLine.Tests.csproj b/test/PSReadLine.Tests.csproj index b9cea03c6..3cf7aee80 100644 --- a/test/PSReadLine.Tests.csproj +++ b/test/PSReadLine.Tests.csproj @@ -5,7 +5,7 @@ library UnitTestPSReadLine PSReadLine.Tests - net462;net6.0 + net8.0 512 {3AC096D0-A1C2-E12C-1390-A8335801FDAB};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} False @@ -14,28 +14,19 @@ 9.0 - - - - - - - - - - - + + - - - + + + all runtime; build; native; contentfiles; analyzers - - + + diff --git a/test/RenderTest.cs b/test/RenderTest.cs index 647fac673..cc72f367d 100644 --- a/test/RenderTest.cs +++ b/test/RenderTest.cs @@ -182,7 +182,6 @@ public void MultiLine() // Make sure when input is incomplete actually puts a newline // wherever the cursor is. - var continationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; Test("{\n\nd\n}", Keys( '{', _.Enter, CheckThat(() => AssertCursorTopIs(1)), @@ -190,8 +189,8 @@ public void MultiLine() _.Enter, CheckThat(() => AssertCursorTopIs(2)), _.Home, _.RightArrow, CheckThat(() => AssertCursorLeftTopIs(1, 0)), - _.Enter, CheckThat(() => AssertCursorLeftTopIs(continationPrefixLength, 1)), - _.End, CheckThat(() => AssertCursorLeftTopIs(continationPrefixLength, 3)), + _.Enter, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 1)), + _.End, CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength, 3)), '}')); // Make sure when input successfully parses accepts the input regardless @@ -206,6 +205,8 @@ public void MultiLine() [SkippableFact] public void MultiLine_ScreenCheck() { + Skip.If(ScreenReaderModeEnabled, "Continuation prompt is not supported in screen reader mode."); + TestSetup(KeyMode.Cmd); var defaultContinuationPrompt = PSConsoleReadLineOptions.DefaultContinuationPrompt; @@ -306,8 +307,16 @@ public void InvokePrompt() CheckThat(() => AssertScreenIs(1, Tuple.Create(_console.ForegroundColor, _console.BackgroundColor), "PSREADLINE> ", TokenClassification.Command, "dir")))); + } + + [SkippableFact] + public void InvokeTrickyPrompt() + { + Skip.If(ScreenReaderModeEnabled, "We can't test the colors written in this prompt in screen reader mode."); + + TestSetup(KeyMode.Cmd, new KeyHandler("Ctrl+z", PSConsoleReadLine.InvokePrompt)); - // Tricky prompt - writes to console directly with colors, uses ^H trick to eliminate trailng space. + // Tricky prompt - writes to console directly with colors, uses ^H trick to eliminate trailing space. using (var ps = PowerShell.Create(RunspaceMode.CurrentRunspace)) { ps.AddCommand("New-Variable").AddParameter("Name", "__console").AddParameter("Value", _console).Invoke(); diff --git a/test/StringBuilderCharacterExtensionsTests.cs b/test/StringBuilderCharacterExtensionsTests.cs new file mode 100644 index 000000000..064477a93 --- /dev/null +++ b/test/StringBuilderCharacterExtensionsTests.cs @@ -0,0 +1,46 @@ +ο»Ώusing Microsoft.PowerShell; +using System.Text; +using Xunit; + +namespace Test +{ + public sealed class StringBuilderCharacterExtensionsTests + { + [Fact] + public void StringBuilderCharacterExtensions_IsVisibleBlank() + { + var buffer = new StringBuilder(" \tn"); + + // system under test + + Assert.True(buffer.IsVisibleBlank(0)); + Assert.True(buffer.IsVisibleBlank(1)); + Assert.False(buffer.IsVisibleBlank(2)); + } + + [Fact] + public void StringBuilderCharacterExtensions_InWord() + { + var buffer = new StringBuilder("hello, world!"); + const string wordDelimiters = " "; + + // system under test + + Assert.True(buffer.InWord(2, wordDelimiters)); + Assert.True(buffer.InWord(5, wordDelimiters)); + } + + [Fact] + public void StringBuilderCharacterExtensions_IsWhiteSpace() + { + var buffer = new StringBuilder("a c"); + + + // system under test + + Assert.False(buffer.IsWhiteSpace(0)); + Assert.True(buffer.IsWhiteSpace(1)); + Assert.False(buffer.IsWhiteSpace(2)); + } + } +} diff --git a/test/StringBuilderTextObjectExtensionsTests.cs b/test/StringBuilderTextObjectExtensionsTests.cs new file mode 100644 index 000000000..66bd590de --- /dev/null +++ b/test/StringBuilderTextObjectExtensionsTests.cs @@ -0,0 +1,77 @@ +ο»Ώusing Microsoft.PowerShell; +using System.Text; +using Xunit; + +namespace Test +{ + public sealed class StringBuilderTextObjectExtensionsTests + { + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!\ncruel world.\none\n\n\n\n\ntwo\n three four."); + Assert.Equal(0, buffer.ViFindBeginningOfWordObjectBoundary(1, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_whitespace() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!"); + Assert.Equal(6, buffer.ViFindBeginningOfWordObjectBoundary(7, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_backwards() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello!\nworld!"); + Assert.Equal(5, buffer.ViFindBeginningOfWordObjectBoundary(6, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfWordObjectBoundary_end_of_buffer() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!"); + Assert.Equal(12, buffer.ViFindBeginningOfWordObjectBoundary(buffer.Length, wordDelimiters)); + } + + [Fact] + public void StringBuilderTextObjectExtensions_ViFindBeginningOfNextWordObjectBoundary() + { + const string wordDelimiters = PSConsoleReadLineOptions.DefaultWordDelimiters; + + var buffer = new StringBuilder("Hello, world!\ncruel world.\none\n\n\n\n\ntwo\n three four."); + + // Words |Hello|,| |world|!|\n|cruel |world|.|\n|one\n\n|\n\n|\n|two|\n |three| |four|.| + // Pos 01234 5 6 78901 2 _3 456789 01234 5 _6 789_0_1 _2_3 _4 567 _89 01234 5 6789 0 + // Pos 0 1 2 3 4 5 + + // system under test + + Assert.Equal(5, buffer.ViFindBeginningOfNextWordObjectBoundary(0, wordDelimiters)); + Assert.Equal(6, buffer.ViFindBeginningOfNextWordObjectBoundary(5, wordDelimiters)); + Assert.Equal(7, buffer.ViFindBeginningOfNextWordObjectBoundary(6, wordDelimiters)); + Assert.Equal(12, buffer.ViFindBeginningOfNextWordObjectBoundary(7, wordDelimiters)); + Assert.Equal(13, buffer.ViFindBeginningOfNextWordObjectBoundary(12, wordDelimiters)); + Assert.Equal(19, buffer.ViFindBeginningOfNextWordObjectBoundary(13, wordDelimiters)); + Assert.Equal(20, buffer.ViFindBeginningOfNextWordObjectBoundary(19, wordDelimiters)); + Assert.Equal(25, buffer.ViFindBeginningOfNextWordObjectBoundary(20, wordDelimiters)); + Assert.Equal(26, buffer.ViFindBeginningOfNextWordObjectBoundary(25, wordDelimiters)); + Assert.Equal(30, buffer.ViFindBeginningOfNextWordObjectBoundary(26, wordDelimiters)); + Assert.Equal(32, buffer.ViFindBeginningOfNextWordObjectBoundary(30, wordDelimiters)); + Assert.Equal(34, buffer.ViFindBeginningOfNextWordObjectBoundary(32, wordDelimiters)); + Assert.Equal(38, buffer.ViFindBeginningOfNextWordObjectBoundary(34, wordDelimiters)); + Assert.Equal(40, buffer.ViFindBeginningOfNextWordObjectBoundary(38, wordDelimiters)); + Assert.Equal(45, buffer.ViFindBeginningOfNextWordObjectBoundary(40, wordDelimiters)); + Assert.Equal(46, buffer.ViFindBeginningOfNextWordObjectBoundary(45, wordDelimiters)); + Assert.Equal(50, buffer.ViFindBeginningOfNextWordObjectBoundary(46, wordDelimiters)); + } + } +} diff --git a/test/TextObjects.Vi.Tests.cs b/test/TextObjects.Vi.Tests.cs new file mode 100644 index 000000000..d38e44bc6 --- /dev/null +++ b/test/TextObjects.Vi.Tests.cs @@ -0,0 +1,170 @@ +using Microsoft.PowerShell; +using Xunit; + +namespace Test +{ + public partial class ReadLine + { + [SkippableFact] + public void ViTextObject_diw() + { + TestSetup(KeyMode.Vi); + + Test("\"hello, \ncruel world!\"", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move cursor to the 'o' in 'world' + "gg9l", + + // delete text object + "diw", + CheckThat(() => AssertLineIs("\"hello, !\ncruel world!\"")), + CheckThat(() => AssertCursorLeftIs(8)), + + // delete + "diw", + CheckThat(() => AssertLineIs("\"hello, \ncruel world!\"")), + CheckThat(() => AssertCursorLeftIs(7)) + )); + } + + [SkippableFact] + public void ViTextObject_diw_digit_arguments() + { + TestSetup(KeyMode.Vi); + + Test("\"hello, world!\"", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move cursor to the 'o' in 'world' + "gg9l", + + // delete text object + "diw", + CheckThat(() => AssertLineIs("\"hello, !\ncruel world!\"")), + CheckThat(() => AssertCursorLeftIs(8)), + + // delete multiple text objects (spans multiple lines) + "3diw", + CheckThat(() => AssertLineIs("\"hello, world!\"")), + CheckThat(() => AssertCursorLeftIs(8)) + )); + } + + + [SkippableFact] + public void ViTextObject_diw_noop() + { + TestSetup(KeyMode.Vi); + + TestMustDing("\"hello, world!\ncruel world!\"", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move cursor to the 'o' in 'world' + "gg9l", + + // attempting to delete too many words must ding + "1274diw" + )); + } + + [SkippableFact] + public void ViTextObject_diw_empty_line() + { + TestSetup(KeyMode.Vi); + + Test("\"\nhello, world!\n\noh, bitter world!\n\"", Keys( + _.DQuote, _.Enter, + "hello, world!", _.Enter, + _.Enter, + "oh, bitter world!", _.Enter, + _.DQuote, _.Escape, + + // move cursor to the second line + "ggjj", + + // deleting single word cannot move backwards to previous line (noop) + "diw", + CheckThat(() => AssertLineIs("\"\nhello, world!\n\noh, bitter world!\n\"")) + )); + } + + [SkippableFact] + public void ViTextObject_diw_end_of_buffer() + { + TestSetup(KeyMode.Vi); + + Test("", Keys( + _.DQuote, + "hello, world!", _.Enter, + "cruel world!", _.DQuote, + _.Escape, + + // move to end of buffer + "G$", + + // delete text object (deletes backwards) + "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel world")), + "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel ")), + "diw", CheckThat(() => AssertLineIs("\"hello, world!\ncruel")), + "diw", CheckThat(() => AssertLineIs("\"hello, world!\n")), + "diw", CheckThat(() => AssertLineIs("\"hello, world")), + "diw", CheckThat(() => AssertLineIs("\"hello, ")), + "diw", CheckThat(() => AssertLineIs("\"hello,")), + "diw", CheckThat(() => AssertLineIs("\"hello")), + "diw", CheckThat(() => AssertLineIs("\"")), + "diw", CheckThat(() => AssertLineIs("")) + )); + } + + [SkippableFact] + public void ViTextObject_diw_empty_buffer() + { + TestSetup(KeyMode.Vi); + Test("", Keys(_.Escape, "diw")); + TestMustDing("", Keys(_.Escape, "d2iw")); + } + + [SkippableFact] + public void ViTextObject_diw_new_lines() + { + TestSetup(KeyMode.Vi); + + Test("\"\ntwo\n\"", Keys( + _.DQuote, _.Enter, + "one", _.Enter, + _.Enter, _.Enter, + _.Enter, _.Enter, + _.Enter, + "two", _.Enter, _.DQuote, + _.Escape, + + // move to the beginning of 'one' + "gg0j", + + // delete text object + "2diw", + CheckThat(() => AssertLineIs("\"\n\n\n\n\ntwo\n\"")), + + "ugg0j", // currently undo does not move the cursor to the correct position + // delete multiple text objects (spans multiple lines) + "3diw", + CheckThat(() => AssertLineIs("\"\n\n\ntwo\n\"")), + + "ugg0j", // currently undo does not move the cursor to the correct position + // delete multiple text objects (spans multiple lines) + "4diw", + CheckThat(() => AssertLineIs("\"\ntwo\n\"")) + )); + } + } +} diff --git a/test/UnitTestReadLine.cs b/test/UnitTestReadLine.cs index 2399fb47e..81cae6c6d 100644 --- a/test/UnitTestReadLine.cs +++ b/test/UnitTestReadLine.cs @@ -27,7 +27,7 @@ internal class MockedMethods : IPSConsoleReadLineMockableMethods internal Guid acceptedPredictorId; internal string acceptedSuggestion; internal string helpContentRendered; - internal Dictionary> displayedSuggestions = new Dictionary>(); + internal Dictionary> displayedSuggestions = new(); internal void ClearPredictionFields() { @@ -130,6 +130,9 @@ protected ReadLine(ConsoleFixture fixture, ITestOutputHelper output, string lang internal virtual bool KeyboardHasGreaterThan => true; internal virtual bool KeyboardHasCtrlRBracket => true; internal virtual bool KeyboardHasCtrlAt => true; + internal virtual bool ScreenReaderModeEnabled => false; + + internal int ContinuationPromptLength => _continuationPromptLength; static ReadLine() { @@ -444,8 +447,13 @@ private void AssertScreenIs(int top, int lines, params object[] items) // that shouldn't be and aren't ever set by any code in PSReadLine, so we'll // ignore those bits and just check the stuff we do set. Assert.Equal(expectedBuffer[i].UnicodeChar, consoleBuffer[i].UnicodeChar); - Assert.Equal(expectedBuffer[i].ForegroundColor, consoleBuffer[i].ForegroundColor); - Assert.Equal(expectedBuffer[i].BackgroundColor, consoleBuffer[i].BackgroundColor); + if (!ScreenReaderModeEnabled) + { + // Changing colors is not supported in screen reader mode, + // and this is the simplest way to disable checking that in all the tests. + Assert.Equal(expectedBuffer[i].ForegroundColor, consoleBuffer[i].ForegroundColor); + Assert.Equal(expectedBuffer[i].BackgroundColor, consoleBuffer[i].BackgroundColor); + } } } @@ -537,6 +545,10 @@ private void TestMustDing(string expectedResult, object[] items) private string _emptyLine; private TestConsole _console; private MockedMethods _mockedMethods; + private bool _oneTimeInitCompleted; + private object _psrlInstance; + private FieldInfo _psrlConsole, _psrlMockableMethods; + private int _continuationPromptLength; private static string MakeCombinedColor(ConsoleColor fg, ConsoleColor bg) => VTColorUtils.AsEscapeSequence(fg) + VTColorUtils.AsEscapeSequence(bg, isBackground: true); @@ -553,14 +565,17 @@ private void TestSetup(TestConsole console, KeyMode keyMode, params KeyHandler[] _console = console ?? new TestConsole(_); _mockedMethods = new MockedMethods(); - var instance = (PSConsoleReadLine)typeof(PSConsoleReadLine) - .GetField("_singleton", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); - typeof(PSConsoleReadLine) - .GetField("_mockableMethods", BindingFlags.Instance | BindingFlags.NonPublic) - .SetValue(instance, _mockedMethods); - typeof(PSConsoleReadLine) - .GetField("_console", BindingFlags.Instance | BindingFlags.NonPublic) - .SetValue(instance, _console); + + if (_psrlInstance is null) + { + Type psrlType = typeof(PSConsoleReadLine); + _psrlInstance = psrlType.GetField("_singleton", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); + _psrlConsole = psrlType.GetField("_console", BindingFlags.Instance | BindingFlags.NonPublic); + _psrlMockableMethods = psrlType.GetField("_mockableMethods", BindingFlags.Instance | BindingFlags.NonPublic); + } + + _psrlConsole.SetValue(_psrlInstance, _console); + _psrlMockableMethods.SetValue(_psrlInstance, _mockedMethods); _emptyLine ??= new string(' ', _console.BufferWidth); @@ -576,6 +591,7 @@ private void TestSetup(TestConsole console, KeyMode keyMode, params KeyHandler[] ContinuationPrompt = PSConsoleReadLineOptions.DefaultContinuationPrompt, DingDuration = 1, // Make tests virtually silent when they ding DingTone = 37, // Make tests virtually silent when they ding + EnableScreenReaderMode = ScreenReaderModeEnabled, ExtraPromptLineCount = PSConsoleReadLineOptions.DefaultExtraPromptLineCount, HistoryNoDuplicates = PSConsoleReadLineOptions.DefaultHistoryNoDuplicates, HistorySaveStyle = HistorySaveStyle.SaveNothing, @@ -626,6 +642,18 @@ private void TestSetup(TestConsole console, KeyMode keyMode, params KeyHandler[] } var colorOptions = new SetPSReadLineOption {Colors = colors}; PSConsoleReadLine.SetOptions(colorOptions); + + // Cache the continuation prompt length for use in tests + _continuationPromptLength = ScreenReaderModeEnabled + ? 0 + : PSConsoleReadLine.GetOptions().ContinuationPrompt.Length; + + if (!_oneTimeInitCompleted) + { + typeof(PSConsoleReadLine).GetMethod("Initialize", BindingFlags.Instance | BindingFlags.NonPublic) + .Invoke(_psrlInstance, new object[] { /* Runspace */ null, /* EngineIntrinsics */ null, }); + _oneTimeInitCompleted = true; + } } } @@ -655,4 +683,14 @@ public fr_FR_Windows(ConsoleFixture fixture, ITestOutputHelper output) internal override bool KeyboardHasCtrlRBracket => false; internal override bool KeyboardHasCtrlAt => false; } + + public class ScreenReader : Test.ReadLine, IClassFixture + { + public ScreenReader(ConsoleFixture fixture, ITestOutputHelper output) + : base(fixture, output, "en-US", "windows") + { + } + + internal override bool ScreenReaderModeEnabled => true; + } } diff --git a/test/YankPasteTest.VI.cs b/test/YankPasteTest.VI.cs index 590f04be1..56261d5b6 100644 --- a/test/YankPasteTest.VI.cs +++ b/test/YankPasteTest.VI.cs @@ -48,13 +48,11 @@ public void ViPasteAfterDelete() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("abcd", Keys( "abcd", _.Escape, "dd", CheckThat(() => AssertLineIs("")), CheckThat(() => AssertCursorLeftIs(0)), - 'p', CheckThat(() => AssertLineIs("\nabcd")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), - 'P', CheckThat(() => AssertLineIs("\nabcd\nabcd")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + 'p', CheckThat(() => AssertLineIs("\nabcd")), CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), + 'P', CheckThat(() => AssertLineIs("\nabcd\nabcd")), CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), "uuu" )); @@ -206,12 +204,10 @@ public void ViPasteAfterDeleteLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("abc def", Keys( "abc def", _.Escape, "dd", CheckThat(() => AssertLineIs("")), CheckThat(() => AssertCursorLeftIs(0)), - 'p', CheckThat(() => AssertLineIs("\nabc def")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + 'p', CheckThat(() => AssertLineIs("\nabc def")), CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), "dd", CheckThat(() => AssertLineIs("")), CheckThat(() => AssertCursorLeftIs(0)), 'P', CheckThat(() => AssertLineIs("abc def\n")), CheckThat(() => AssertCursorLeftIs(0)), "uuuu" @@ -223,13 +219,11 @@ public void ViPasteAfterYankLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("012 456", Keys( "012 456", _.Escape, "byyP", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(0)), "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(4)), - "p", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), + "p", CheckThat(() => AssertLineIs("012 456\n012 456")), CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), "u", CheckThat(() => AssertLineIs("012 456")), CheckThat(() => AssertCursorLeftIs(4)) )); } @@ -338,8 +332,6 @@ public void ViPasteAfterYankBeginningOfLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("012", Keys( "012", _.Escape, "y0P", CheckThat(() => AssertLineIs("01012")), CheckThat(() => AssertCursorLeftIs(1)), @@ -358,9 +350,9 @@ public void ViPasteAfterYankBeginningOfLine() " World!", _.Enter, _.DQuote, _.Escape, _.k, "5l", // move the cursor to the 'd' character of "World!" - "y0", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)), - "P", CheckThat(() => AssertLineIs("\"\nHello\n Worl World!\n\"")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 4)), - "u", CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 0)) + "y0", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)), + "P", CheckThat(() => AssertLineIs("\"\nHello\n Worl World!\n\"")), CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 4)), + "u", CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 0)) )); } @@ -369,15 +361,13 @@ public void ViPasteAfterYankEndOfLine() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\nHello\nWorld!\n\"", Keys( _.DQuote, _.Enter, "Hello", _.Enter, "World!", _.Enter, _.DQuote, _.Escape, _.k, _.l, // move to the 'o' character of 'World!' - "y$P", CheckThat(() => AssertLineIs("\"\nHello\nWorld!orld!\n\"")), CheckThat(() => AssertCursorLeftIs(continuationPrefixLength + 5)), + "y$P", CheckThat(() => AssertLineIs("\"\nHello\nWorld!orld!\n\"")), CheckThat(() => AssertCursorLeftIs(ContinuationPromptLength + 5)), "u" )); } @@ -542,15 +532,13 @@ public void ViDeleteAndPasteLogicalLines() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\nline1\nline1\nline2\nline2\n\"", Keys( _.DQuote, _.Enter, "line1", _.Enter, "line2", _.Enter, _.DQuote, _.Escape, _.k, _.k, - "2dd", 'P', 'p', CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 0, 2)) + "2dd", 'P', 'p', CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 0, 2)) )); } @@ -559,15 +547,13 @@ public void ViDeleteAndPasteLogicalLines_Underbar() { TestSetup(KeyMode.Vi); - var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length; - Test("\"\nline1\nline1\nline2\nline2\n\"", Keys( _.DQuote, _.Enter, "line1", _.Enter, "line2", _.Enter, _.DQuote, _.Escape, _.k, _.k, - "2d", _.Underbar, 'P', 'p', CheckThat(() => AssertCursorLeftTopIs(continuationPrefixLength + 0, 2)) + "2d", _.Underbar, 'P', 'p', CheckThat(() => AssertCursorLeftTopIs(ContinuationPromptLength + 0, 2)) )); } diff --git a/tools/CheckHelp.ps1 b/tools/CheckHelp.ps1 index 2a3caa62c..bc37b540d 100644 --- a/tools/CheckHelp.ps1 +++ b/tools/CheckHelp.ps1 @@ -13,7 +13,7 @@ Import-Module $PSScriptRoot/helper.psm1 $t ='Microsoft.PowerShell.PSConsoleReadLine' -as [type] if ($null -ne $t) { - # Make sure we're runnning in a non-interactive session by relaunching + # Make sure we're running in a non-interactive session by relaunching $psExePath = Get-PSExePath & $psExePath -NoProfile -NonInteractive -File $PSCommandPath $Configuration exit $LASTEXITCODE diff --git a/tools/helper.psm1 b/tools/helper.psm1 index 29ba9f06f..56776ea9a 100644 --- a/tools/helper.psm1 +++ b/tools/helper.psm1 @@ -1,9 +1,13 @@ -$MinimalSDKVersion = '6.0.100' $IsWindowsEnv = [System.Environment]::OSVersion.Platform -eq "Win32NT" $RepoRoot = (Resolve-Path "$PSScriptRoot/..").Path $LocalDotnetDirPath = if ($IsWindowsEnv) { "$env:LocalAppData\Microsoft\dotnet" } else { "$env:HOME/.dotnet" } +# Read the required SDK version from global.json +$globalJsonPath = Join-Path $RepoRoot "global.json" +$globalJson = Get-Content -Path $globalJsonPath -Raw | ConvertFrom-Json +$MinimalSDKVersion = $globalJson.sdk.version + <# .SYNOPSIS Get the path of the currently running powershell executable. @@ -54,7 +58,7 @@ function Test-DotnetSDK if (Test-Path $dotnetExePath) { $installedVersion = & $dotnetExePath --version - return $installedVersion -ge $MinimalSDKVersion + return [Version]::new($installedVersion) -ge [Version]::new($MinimalSDKVersion) } return $false } @@ -210,7 +214,16 @@ function Start-TestRun function RunXunitTestsInNewProcess ([string] $Layout, [string] $OperatingSystem) { - $filter = "FullyQualifiedName~Test.{0}_{1}" -f ($Layout -replace '-','_'), $OperatingSystem + $filter = if ($Layout) { + Write-Log "`nTesting $Layout on $OperatingSystem..." + "FullyQualifiedName~Test.{0}_{1}" -f ($Layout -replace '-','_'), $OperatingSystem + } else { + ## Today, tests for screen-reader mode only run on Windows with the 'en-US' layout. + $Layout = "screen-reader" + Write-Log "`nTesting $Layout mode..." + "FullyQualifiedName~Test.ScreenReader" + } + $testResultFile = "xUnitTestResults.{0}.xml" -f $Layout $testResultFile = Join-Path $testResultFolder $testResultFile @@ -263,7 +276,6 @@ function Start-TestRun { if (Test-Path "KeyInfo-${layout}-windows.json") { - Write-Log "Testing $layout ..." $null = [KeyboardLayoutHelper]::SetKeyboardLayout($layout) # We have to use Start-Process so it creates a new window, because the keyboard @@ -273,7 +285,7 @@ function Start-TestRun } else { - Write-Log "Testing not supported for the keyboard layout '$layout'." + Write-Log "`nTesting not supported for the keyboard layout '$layout'." } } } @@ -283,6 +295,8 @@ function Start-TestRun $null = [KeyboardLayoutHelper]::SetKeyboardLayout($savedLayout) } } + + RunXunitTestsInNewProcess } else { @@ -339,3 +353,26 @@ function Test-XUnitTestResults throw "$($failedTests.failed) tests failed" } } + +<# +.SYNOPSIS + Run 'dotnet restore' for all the target runtime that we are interested in to + update packages on the CFS feed. + This needs to be run on a MS employee's dev machine whenever there is update + to the NuGet packages used in PSReadLine repo, so that the package and all its + dependencies can be pull into the CFS feed from upstream feed. +#> +function Update-CFSFeed +{ + $rids = @('win-x64', 'win-arm64', 'linux-x64', 'linux-arm', 'linux-arm64', 'osx-x64', 'osx-arm64') + + Write-Host "1. clear all NuGet caches on the local machine." -ForegroundColor Green + dotnet nuget locals all -c + + Write-Host "2. restore for target runtimes." -ForegroundColor Green + foreach ($rid in $rids) { + Write-Host " - $rid" -ForegroundColor Green + dotnet restore -r $rid ../test/PSReadLine.Tests.csproj + dotnet restore -r $rid ../MockPSConsole/MockPSConsole.csproj + } +} diff --git a/tools/issue-mgmt/CloseDupIssues.ps1 b/tools/issue-mgmt/CloseDupIssues.ps1 index ab5d5726c..38648f72f 100644 --- a/tools/issue-mgmt/CloseDupIssues.ps1 +++ b/tools/issue-mgmt/CloseDupIssues.ps1 @@ -11,7 +11,7 @@ class issue $repo_name = "PowerShell/PSReadLine" $root_url = "https://github.com/PowerShell/PSReadLine/issues" $msg_upgrade = @" -Please upgrade to the [2.2.3 version of PSReadLine](https://www.powershellgallery.com/packages/PSReadLine/2.2.3) from PowerShell Gallery. +Please upgrade to the [2.3.5 version of PSReadLine](https://www.powershellgallery.com/packages/PSReadLine/2.3.5) from PowerShell Gallery. See the [upgrading section](https://github.com/PowerShell/PSReadLine#upgrading) for instructions. Please let us know if you run into the same issue with the latest version. "@ @@ -46,7 +46,7 @@ foreach ($item in $issues) $body -match 'PSReadLine: 2\.2\.0-beta[12]') { $comment = @' -This issue was fixed in 2.2.0-beta3 version of PSReadLine. You can fix this by upgrading to the latest [2.2.3 version of PSReadLine](https://www.powershellgallery.com/packages/PSReadLine/2.2.3). +This issue was fixed in 2.2.0-beta3 version of PSReadLine. You can fix this by upgrading to the latest [2.3.5 version of PSReadLine](https://www.powershellgallery.com/packages/PSReadLine/2.3.5). To upgrade, simply run `Install-Module PSReadLine -AllowPrerelease -Force` from your PowerShell console. -------- diff --git a/tools/releaseTools.psm1 b/tools/releaseTools.psm1 index 3437e93a7..8fab92e1b 100644 --- a/tools/releaseTools.psm1 +++ b/tools/releaseTools.psm1 @@ -168,7 +168,7 @@ function Get-ChangeLog ## but not reachable from the last release tag. Instead, we need to exclude the commits that were cherry-picked, ## and only include the commits that are not in the last release into the change log. - # Find the commits that were only in the orginal master, excluding those that were cherry-picked to release branch. + # Find the commits that were only in the original master, excluding those that were cherry-picked to release branch. $new_commits_from_other_parent = git --no-pager log --first-parent --cherry-pick --right-only "$tag_hash...$other_parent_hash" --format=$format | New-CommitNode # Find the commits that were only in the release branch, excluding those that were cherry-picked from master branch. $new_commits_from_last_release = git --no-pager log --first-parent --cherry-pick --left-only "$tag_hash...$other_parent_hash" --format=$format | New-CommitNode