Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 22b95ae

Browse files
committedApr 3, 2024·
Use VS Code's built-in shell integration script
Instead of having to maintain an edited copy (which was really annoying) I stubbed out `PSConsoleHostReadLine` to do what they expect. So now we can just use their script directly! We do have to pass the path to Code from the client to most accurately find and execute the script, so that was added.
1 parent 20f90a9 commit 22b95ae

File tree

3 files changed

+56
-213
lines changed

3 files changed

+56
-213
lines changed
 

‎src/PowerShellEditorServices/Server/PsesLanguageServer.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ public async Task StartAsync()
154154
?? workspaceService.WorkspaceFolders.FirstOrDefault()?.Uri.GetFileSystemPath()
155155
?? Directory.GetCurrentDirectory(),
156156
ShellIntegrationEnabled = initializationOptions?.GetValue("shellIntegrationEnabled")?.Value<bool>()
157-
?? false
157+
?? false,
158+
// Leave null if not set as there's nothing to safely assume.
159+
ClientExePath = initializationOptions?.GetValue("clientExePath")?.Value<string>()
158160
};
159161

160162
workspaceService.InitialWorkingDirectory = hostStartOptions.InitialWorkingDirectory;

‎src/PowerShellEditorServices/Services/PowerShell/Host/HostStartOptions.cs

+2
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,7 @@ internal struct HostStartOptions
1010
public string InitialWorkingDirectory { get; set; }
1111

1212
public bool ShellIntegrationEnabled { get; set; }
13+
14+
public string ClientExePath { get; set; }
1315
}
1416
}

‎src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs

+51-212
Original file line numberDiff line numberDiff line change
@@ -317,14 +317,28 @@ public async Task<bool> TryStartAsync(HostStartOptions startOptions, Cancellatio
317317

318318
if (startOptions.ShellIntegrationEnabled)
319319
{
320-
_logger.LogDebug("Enabling shell integration...");
320+
_logger.LogDebug("Enabling VS Code's shell integration...");
321321
_shellIntegrationEnabled = true;
322-
await EnableShellIntegrationAsync(cancellationToken).ConfigureAwait(false);
322+
// Hopefully we were passed `process.argv0` from the VS Code client correctly,
323+
// otherwise try assuming it's just in the PATH.
324+
string code = startOptions.ClientExePath ?? "code";
325+
// TODO: Make the __psEditorServices prefix shared (it's used elsewhere too).
326+
string shellIntegrationScript = $$"""
327+
# Setup VS Code's Terminal Shell Integration.
328+
329+
# Define fake PSConsoleHostReadLine so VS Code's script can execute it to get the user's input.
330+
$global:__psEditorServices_userInput = "";
331+
function global:PSConsoleHostReadLine { $global:__psEditorServices_userInput }
332+
333+
# Find and execute VS Code's built-in shell integration script.
334+
try { . "$(& "{{code}}" --locate-shell-integration-path pwsh)" } catch {}
335+
""";
336+
await EnableShellIntegrationAsync(shellIntegrationScript, cancellationToken).ConfigureAwait(false);
323337
_logger.LogDebug("Shell integration enabled!");
324338
}
325339
else
326340
{
327-
_logger.LogDebug("Shell integration not enabled!");
341+
_logger.LogDebug("VS Code's shell integration not enabled!");
328342
}
329343

330344
await _started.Task.ConfigureAwait(false);
@@ -495,6 +509,7 @@ public Task ExecuteDelegateAsync(
495509
new SynchronousDelegateTask(_logger, representation, executionOptions, action, cancellationToken));
496510
}
497511

512+
// TODO: One day fix these so the cancellation token is last.
498513
public Task<IReadOnlyList<TResult>> ExecutePSCommandAsync<TResult>(
499514
PSCommand psCommand,
500515
CancellationToken cancellationToken,
@@ -581,210 +596,14 @@ internal Task LoadHostProfilesAsync(CancellationToken cancellationToken)
581596
cancellationToken);
582597
}
583598

584-
private Task EnableShellIntegrationAsync(CancellationToken cancellationToken)
585-
{
586-
// Imported on 01/03/24 from
587-
// https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/terminal/browser/media/shellIntegration.ps1
588-
// with quotes escaped, `__VSCodeOriginalPSConsoleHostReadLine` removed (as it's done
589-
// in our own ReadLine function), and `[Console]::Write` replaced with `Write-Host`.
590-
const string shellIntegrationScript = @"
591-
# Prevent installing more than once per session
592-
if (Test-Path variable:global:__VSCodeOriginalPrompt) {
593-
return;
594-
}
595-
596-
# Disable shell integration when the language mode is restricted
597-
if ($ExecutionContext.SessionState.LanguageMode -ne ""FullLanguage"") {
598-
return;
599-
}
600-
601-
$Global:__VSCodeOriginalPrompt = $function:Prompt
602-
603-
$Global:__LastHistoryId = -1
604-
605-
# Store the nonce in script scope and unset the global
606-
$Nonce = $env:VSCODE_NONCE
607-
$env:VSCODE_NONCE = $null
608-
609-
if ($env:VSCODE_ENV_REPLACE) {
610-
$Split = $env:VSCODE_ENV_REPLACE.Split("":"")
611-
foreach ($Item in $Split) {
612-
$Inner = $Item.Split('=')
613-
[Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':'))
614-
}
615-
$env:VSCODE_ENV_REPLACE = $null
616-
}
617-
if ($env:VSCODE_ENV_PREPEND) {
618-
$Split = $env:VSCODE_ENV_PREPEND.Split("":"")
619-
foreach ($Item in $Split) {
620-
$Inner = $Item.Split('=')
621-
[Environment]::SetEnvironmentVariable($Inner[0], $Inner[1].Replace('\x3a', ':') + [Environment]::GetEnvironmentVariable($Inner[0]))
622-
}
623-
$env:VSCODE_ENV_PREPEND = $null
624-
}
625-
if ($env:VSCODE_ENV_APPEND) {
626-
$Split = $env:VSCODE_ENV_APPEND.Split("":"")
627-
foreach ($Item in $Split) {
628-
$Inner = $Item.Split('=')
629-
[Environment]::SetEnvironmentVariable($Inner[0], [Environment]::GetEnvironmentVariable($Inner[0]) + $Inner[1].Replace('\x3a', ':'))
630-
}
631-
$env:VSCODE_ENV_APPEND = $null
632-
}
633-
634-
function Global:__VSCode-Escape-Value([string]$value) {
635-
# NOTE: In PowerShell v6.1+, this can be written `$value -replace '…', { … }` instead of `[regex]::Replace`.
636-
# Replace any non-alphanumeric characters.
637-
[regex]::Replace($value, '[\\\n;]', { param($match)
638-
# Encode the (ascii) matches as `\x<hex>`
639-
-Join (
640-
[System.Text.Encoding]::UTF8.GetBytes($match.Value) | ForEach-Object { '\x{0:x2}' -f $_ }
641-
)
642-
})
643-
}
644-
645-
function Global:Prompt() {
646-
$FakeCode = [int]!$global:?
647-
# NOTE: We disable strict mode for the scope of this function because it unhelpfully throws an
648-
# error when $LastHistoryEntry is null, and is not otherwise useful.
649-
Set-StrictMode -Off
650-
$LastHistoryEntry = Get-History -Count 1
651-
# Skip finishing the command if the first command has not yet started
652-
if ($Global:__LastHistoryId -ne -1) {
653-
if ($LastHistoryEntry.Id -eq $Global:__LastHistoryId) {
654-
# Don't provide a command line or exit code if there was no history entry (eg. ctrl+c, enter on no command)
655-
$Result = ""$([char]0x1b)]633;E`a""
656-
$Result += ""$([char]0x1b)]633;D`a""
657-
}
658-
else {
659-
# Command finished command line
660-
# OSC 633 ; E ; <CommandLine?> ; <Nonce?> ST
661-
$Result = ""$([char]0x1b)]633;E;""
662-
# Sanitize the command line to ensure it can get transferred to the terminal and can be parsed
663-
# correctly. This isn't entirely safe but good for most cases, it's important for the Pt parameter
664-
# to only be composed of _printable_ characters as per the spec.
665-
if ($LastHistoryEntry.CommandLine) {
666-
$CommandLine = $LastHistoryEntry.CommandLine
667-
}
668-
else {
669-
$CommandLine = """"
670-
}
671-
$Result += $(__VSCode-Escape-Value $CommandLine)
672-
$Result += "";$Nonce""
673-
$Result += ""`a""
674-
# Command finished exit code
675-
# OSC 633 ; D [; <ExitCode>] ST
676-
$Result += ""$([char]0x1b)]633;D;$FakeCode`a""
677-
}
678-
}
679-
# Prompt started
680-
# OSC 633 ; A ST
681-
$Result += ""$([char]0x1b)]633;A`a""
682-
# Current working directory
683-
# OSC 633 ; <Property>=<Value> ST
684-
$Result += if ($pwd.Provider.Name -eq 'FileSystem') { ""$([char]0x1b)]633;P;Cwd=$(__VSCode-Escape-Value $pwd.ProviderPath)`a"" }
685-
# Before running the original prompt, put $? back to what it was:
686-
if ($FakeCode -ne 0) {
687-
Write-Error ""failure"" -ea ignore
688-
}
689-
# Run the original prompt
690-
$Result += $Global:__VSCodeOriginalPrompt.Invoke()
691-
# Write command started
692-
$Result += ""$([char]0x1b)]633;B`a""
693-
$Global:__LastHistoryId = $LastHistoryEntry.Id
694-
return $Result
695-
}
696-
697-
# Set IsWindows property
698-
if ($PSVersionTable.PSVersion -lt ""6.0"") {
699-
# Windows PowerShell is only available on Windows
700-
Write-Host -NoNewLine ""$([char]0x1b)]633;P;IsWindows=$true`a""
701-
}
702-
else {
703-
Write-Host -NoNewLine ""$([char]0x1b)]633;P;IsWindows=$IsWindows`a""
704-
}
705-
706-
# Set always on key handlers which map to default VS Code keybindings
707-
function Set-MappedKeyHandler {
708-
param ([string[]] $Chord, [string[]]$Sequence)
709-
try {
710-
$Handler = Get-PSReadLineKeyHandler -Chord $Chord | Select-Object -First 1
711-
}
712-
catch [System.Management.Automation.ParameterBindingException] {
713-
# PowerShell 5.1 ships with PSReadLine 2.0.0 which does not have -Chord,
714-
# so we check what's bound and filter it.
715-
$Handler = Get-PSReadLineKeyHandler -Bound | Where-Object -FilterScript { $_.Key -eq $Chord } | Select-Object -First 1
716-
}
717-
if ($Handler) {
718-
Set-PSReadLineKeyHandler -Chord $Sequence -Function $Handler.Function
719-
}
720-
}
721-
722-
$Global:__VSCodeHaltCompletions = $false
723-
function Set-MappedKeyHandlers {
724-
Set-MappedKeyHandler -Chord Ctrl+Spacebar -Sequence 'F12,a'
725-
Set-MappedKeyHandler -Chord Alt+Spacebar -Sequence 'F12,b'
726-
Set-MappedKeyHandler -Chord Shift+Enter -Sequence 'F12,c'
727-
Set-MappedKeyHandler -Chord Shift+End -Sequence 'F12,d'
728-
729-
# Conditionally enable suggestions
730-
if ($env:VSCODE_SUGGEST -eq '1') {
731-
Remove-Item Env:VSCODE_SUGGEST
732-
733-
# VS Code send completions request (may override Ctrl+Spacebar)
734-
Set-PSReadLineKeyHandler -Chord 'F12,e' -ScriptBlock {
735-
Send-Completions
736-
}
737-
738-
# Suggest trigger characters
739-
Set-PSReadLineKeyHandler -Chord ""-"" -ScriptBlock {
740-
[Microsoft.PowerShell.PSConsoleReadLine]::Insert(""-"")
741-
if (!$Global:__VSCodeHaltCompletions) {
742-
Send-Completions
743-
}
744-
}
745-
746-
Set-PSReadLineKeyHandler -Chord 'F12,y' -ScriptBlock {
747-
$Global:__VSCodeHaltCompletions = $true
748-
}
749-
750-
Set-PSReadLineKeyHandler -Chord 'F12,z' -ScriptBlock {
751-
$Global:__VSCodeHaltCompletions = $false
752-
}
753-
}
754-
}
755-
756-
function Send-Completions {
757-
$commandLine = """"
758-
$cursorIndex = 0
759-
# TODO: Since fuzzy matching exists, should completions be provided only for character after the
760-
# last space and then filter on the client side? That would let you trigger ctrl+space
761-
# anywhere on a word and have full completions available
762-
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$commandLine, [ref]$cursorIndex)
763-
$completionPrefix = $commandLine
764-
765-
# Get completions
766-
$result = ""`e]633;Completions""
767-
if ($completionPrefix.Length -gt 0) {
768-
# Get and send completions
769-
$completions = TabExpansion2 -inputScript $completionPrefix -cursorColumn $cursorIndex
770-
if ($null -ne $completions.CompletionMatches) {
771-
$result += "";$($completions.ReplacementIndex);$($completions.ReplacementLength);$($cursorIndex);""
772-
$result += $completions.CompletionMatches | ConvertTo-Json -Compress
773-
}
774-
}
775-
$result += ""`a""
776-
777-
Write-Host -NoNewLine $result
778-
}
779-
780-
# Register key handlers if PSReadLine is available
781-
if (Get-Module -Name PSReadLine) {
782-
Set-MappedKeyHandlers
783-
}
784-
";
785-
786-
return ExecutePSCommandAsync(new PSCommand().AddScript(shellIntegrationScript), cancellationToken);
787-
}
599+
private Task EnableShellIntegrationAsync(string shellIntegrationScript, CancellationToken cancellationToken) => ExecutePSCommandAsync(
600+
new PSCommand().AddScript(shellIntegrationScript),
601+
cancellationToken,
602+
new PowerShellExecutionOptions
603+
{
604+
AddToHistory = true, // VS Code adds theirs to the history, so we do too.
605+
ThrowOnError = false
606+
});
788607

789608
public Task SetInitialWorkingDirectoryAsync(string path, CancellationToken cancellationToken)
790609
{
@@ -1262,16 +1081,36 @@ private void InvokeInput(string input, CancellationToken cancellationToken)
12621081

12631082
try
12641083
{
1265-
// For VS Code's shell integration feature, this replaces their
1266-
// PSConsoleHostReadLine function wrapper, as that global function is not available
1267-
// to users of PSES, since we already wrap ReadLine ourselves.
1084+
// For VS Code's shell integration feature, we call PSConsoleHostReadLine specially as they've wrapped it.
1085+
// Normally it would not be available (since we wrap ReadLine ourselves),
1086+
// but in this case we've made the original just emit the user's input so that their wrapper works as intended.
12681087
if (_shellIntegrationEnabled)
12691088
{
1270-
System.Console.Write("\x1b]633;C\a");
1089+
// Save the user's input to our special global variable so PSConsoleHostReadLine can read it.
1090+
InvokePSCommand(
1091+
new PSCommand().AddScript($"$global:__psEditorServices_userInput = \"{input}\""),
1092+
new PowerShellExecutionOptions { ThrowOnError = false },
1093+
cancellationToken);
1094+
1095+
// Invoke VS Code's PSConsoleHostReadLine wrapper.
1096+
InvokePSCommand(
1097+
new PSCommand().AddScript("PSConsoleHostReadLine"),
1098+
new PowerShellExecutionOptions
1099+
{
1100+
ThrowOnError = false,
1101+
WriteOutputToHost = true,
1102+
},
1103+
cancellationToken);
1104+
1105+
// Reset our global variable.
1106+
InvokePSCommand(
1107+
new PSCommand().AddScript("$global:__psEditorServices_userInput = \"\""),
1108+
new PowerShellExecutionOptions { ThrowOnError = false },
1109+
cancellationToken);
12711110
}
12721111

12731112
InvokePSCommand(
1274-
new PSCommand().AddScript(input, useLocalScope: false),
1113+
new PSCommand().AddScript(input),
12751114
new PowerShellExecutionOptions
12761115
{
12771116
AddToHistory = true,

0 commit comments

Comments
 (0)
Please sign in to comment.