PowerShell / PowerShell

PowerShell for every system!
https://microsoft.com/PowerShell
MIT License
43.56k stars 7.06k forks source link

Allow for invoking a visual editor to modify the current command #21525

Closed jimbobmcgee closed 3 weeks ago

jimbobmcgee commented 3 weeks ago

Summary of the new feature / enhancement

As a user, I frequently find myself starting what I think will be a simple command in Powershell, only to find it evolve into a multi-pipeline, multi-bracket hellbeast which spans multiple lines and is impossible to navigate through, and I have to throw my hands up in the air, bang the whole thing into the clipboard (using the mouse of all things) and paste it into an editor.

Of course, then I have to fight any punctuation/newlines introduced by console window's copy/paste (I'm looking at you >> line-leader), hope that I caught them all (and escaped the ones I meant to leave in), and paste the completed command back to the console window. Nine times out of ten, I'll end up highlighting a single blank space in the console window, overwriting the clipboard, and have to go back to the editor to copy again.

All of this is fraught, and it would be nice if a massive chunk of that busywork could be taken away.

Many interactive/terminal apps have the concept of opening a preconfigured visual editor, pre-populated with the command currently in the "buffer" as a temp file, and letting you edit that temp file in the visual editor. When the editor is exited, if the temp file was saved, the content is fed back into the buffer, ready to be executed.

Examples are the prominent database-interacting terminal apps -- consider :ED in SQL Server's sqlcmd; ed in Oracle's sqlplus; or \e in PostgreSQL's psql.

I would like this same facility for PowerShell, and I would like to bind it to a keypress or combo. So, if I press (for example) F8 notepad.exe automatically opens with the current command automagically within and lets me edit. I save and exit notepad.exe, the command buffer is automagically updated and I'm positioned at the end, ideally just before the point where I would normally press Enter and have it execute. If I didn't save the buffer file, the command wouldn't be updated, and would remain unaffected.

This would differ slightly from the above examples: notably those mentioned terminal apps all operate on a buffer of data that is submitted line-by-line, but not executed until a sentinel command is entered; whereas I would prefer that pressing the invocation key opens the current, unsubmitted command. Unfortunately, I am not certain how you would go about obtaining (and subsequently rewriting) the unsubmitted command buffer (but perhaps this is trivial and I am worrying unnecessarily?).

For style points, allow the editor to be in someway configurable -- maybe follow EDITOR or VISUAL environment variables as per non-Windows (or introduce your own $PSEditor); perhaps defaulting to notepad.exe for Windows; and vi for POSIX, if these are not set. I assume that , if following the EDITOR/VISUAL approach, it would allow for any command which takes the path to the buffered temp file as its last argument.

Obviously not all editor apps would be feasible for this (one might struggle with tracking the closure of the temp file if the editor were an MDI/IDE-type app), but that can be documented if it seems non-obvious.

For more style points, I would allow the invocation to operate on any historic command: i.e. allow for navigating to a previous command with /, then hitting the invocation key puts that command in the buffer temp file and opens the editor; only, this time, saving the file would result in a new command, not overwriting the historic one.

I'm sure there is some nuance I haven't considered, but am happy to flesh it out over time.

Proposed technical implementation details (optional)

No response

rhubarb-geek-nz commented 3 weeks ago

I suggest this would be a PSReadLine feature.

MartinGC94 commented 3 weeks ago

There's a really cool module called psedit that lets you get a fullblown editor in your terminal. You could set up a custom keyhandler in PSReadLine which would get the current input buffer, save it to a temp file, open that file with psedit, and replace the input buffer with the file content.

As for this:

I have to throw my hands up in the air, bang the whole thing into the clipboard (using the mouse of all things) and paste it into an editor.

You can press Ctrl+A to select all and Ctrl+C to copy it all. There's no need to use a mouse and deal with the line continuation characters.

237dmitry commented 3 weeks ago

You can use script block to edit command

PS > & {
          edit your command inside this unnamed function
       }
SeeminglyScience commented 3 weeks ago

This is already implemented in PSReadLine. By default if your edit mode is Vi it will be v in command mode. You can also set it in your profile:

Set-PSReadLineKeyHandler -Chord F8 -Function ViEditVisually

It uses $env:EDITOR to determine what application to launch.

jimbobmcgee commented 3 weeks ago

@SeeminglyScience That's good enough for me. I don't use Vi mode, but adding the handler seems to work without being in any specific mode.

Now, if only it could get a binding by default, so I didn't have to add it across every server I have to use.

kilasuit commented 3 weeks ago

@jimbobmcgee that would be needed to be raised as a feature request in the PSReadline repo but is a good candidate for inclusion in a profile script that you can access on any/all servers you work on

mklement0 commented 3 weeks ago

Let me provide some additional context for the ViEditVisually PSReadLine function:

tl;dr:



To overcome these limitations, a custom implementation is required (which too can be placed in $PROFILE), such as the following, which builds on @zett42's helpful Gist:

In terms of UX:

# Custom implementation of the ViEditVisually PSReadLine function.
Set-PSReadLineKeyHandler -Chord 'Alt+e' -ScriptBlock {

  # To support cross-edition use of potentially undefined variables such as $IsMacOS
  Set-StrictMode -Off 

  # Get current buffer text.
  $bufferText = $null; $cursorPos = $null
  [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref] $bufferText, [ref] $cursorPos)

  # Translate the current cursor position on the command line into a line-column pair, to pass
  # to those editors that support positioning the cursor via their CLI.
  $lineNo = 1; $precedingNewLinePos = -1
  foreach ($m in [regex]::Matches($bufferText, '\n')) {
    if ($m.Index -lt $cursorPos) { ++$lineNo; $precedingNewLinePos = $m.Index }
    else { break }
  } 
  $colNo = $cursorPos - $precedingNewLinePos

  # Determine the target editor.
  $editor = foreach ($cmd in $env:VISUAL, $env:EDITOR) { if ($cmd) { $cmd; break } }
  $options = @()
  if ($configuredViaEnvVar = $editor) { # $env:VISUAL or $env:EDITOR is set.
    $orgVal = $editor
    if (-not ($editor = Get-Command -ErrorAction Ignore $editor)) {
      # $editor not being a command means one of two things:
      #  * The editor binary path / command name doesn't exist.
      #  * The env. var. value is an editor binary (path) *plus options*.
      # Check for the latter:
      #  NOTE: Hypothetically, the following command expands "$"-prefixed tokens embedded in the value of $editor.
      #        While this could be considered code injection, the very fact of allowing specification of a *binary itself*, even
      #        by *literal* path - via $env:VISUAL or $env:EDITOR - is a security vulnerability, so this case is not worth guarding against.
      $editor, [array] $options = Invoke-Expression "Write-Output -- $orgVal"
      if (-not ($editor = Get-Command -ErrorAction Ignore $editor)) { throw "Not a valid editor binary name / path / command or command line: «$orgVal»" }
    }
    # Translate alias 'psedit' into the name of its target cmdlet.
    if ($editor -eq 'psedit') { $editor = 'Show-PSEditor' }
  }
  else {
    # No explicitly defined editor - look for candidates in order of preference.
    # Note: So as to also support `psedit` (`Show-PSEditor`), we do not limit the command
    #       lookup to -Type Application
    $editor = Get-Command  -ErrorAction Ignore 'code', 'micro', 'gedit', 'nano', 'vi', 'vim', 'emacs' | Select-Object -First 1
    if (-not $editor) {
      throw 'No suitable modal text editor found.'
    }
  }
  if ($editor.ResolvedCommand) { $editor = $editor.ResolvedCommand }
  # Get the mere editor name, without path and filename extension.
  # Note: For cmdlets, this reports the cmdlet name.
  $editorBaseName = [System.IO.Path]::GetFileNameWithoutExtension($editor)
  # Determine editor characteristics.
  $needTempFile = $editorBaseName -ne 'micro' # Only `micro` supports modal editing via stdin and stdout.
  # *Assume* that editors other than well-known terminal-based editors are GUI editors.
  # Note that we cannot know for sure (hypothetically, on Windows only, the binary could be examined for whether it is a console-subsystem application).
  # !! If a custom editor specified via $env:VISUAL / $env:EDITOR results in the terminal *freezing* on invocation, add its binary base name to this list.
  $isGuiEditor = $editorBaseName -notin 'Show-PSEditor', 'micro', 'mcedit', 'nano', 'vi', 'vim', 'emacs'
  # !! If the target editor is (a) terminal-based and (b) doesn't work when its stdout is redirected, which is MOST of them, 
  # !! we must delegate to the ViEditVisually PSReadLine function.
  # !! Note that redirecting stdout cannot be avoided, because PSReadLine *captures* the output from this -ScriptBlock argument.
  # !! Trying to force output to a terminal with `>/dev/tty` on Unix does NOT work, and on Windows in PS Core only half-works with `>\\.\CON`.
  # !! Redirecting to ViEditVisually PSReadLine function implies:
  # !!  * NO support for passing the cursor position.
  # !!  * We cannot auto-submit the returned buffer, becase we won't know if it was modified.
  # !! The only terminal-based editor NOT affected by a stdout redirection is `micro`.
  # !! We therefore exclude `micro` and `Show-PSEditor`: both have custom handling below, and
  # !! the latter - as a PowerShell cmdlet rather than an external binary - isn't subject to the problem to begin with.
  $mustDelegateToViEditVisually = -not $isGuiEditor -and $editorBaseName -notin 'micro', 'Show-PSEditor'

  if ($VerbosePreference -eq 'Continue') {
    [pscustomobject] @{
      Editor = $editor
      Options = $options
      EditorBaseName = $editorBaseName
      IsGuiEditor = $isGuiEditor
      MustDelegateToViEditVisually = $mustDelegateToViEditVisually
      NeedTempFile = $needTempFile
    } | Out-String | Write-Verbose -Verbose
  }

  if ($mustDelegateToViEditVisually) {
    # !! If options were baked into the $env:VISUAL / $env:EDITOR value, invocation of `ViEditVisually` will fail
    # !! up to at least v2.3.5 of PSReadLine
    if ($options) { Write-Warning "Including pass-through options in `$env:VISUAL or `$env:EDITOR doesn't work up to at least PSReadLine 2.3.5" }
    # !! Using `[Microsoft.PowerShell.PSConsoleReadLine]::ViEditVisually()` as of PSReadLine 2.3.5
    # !! means that passing the command-line cursor position is NOT supported, and that the editor's cursor position
    # !! will inevitably be at the *start* of the editor's buffer.
    [Microsoft.PowerShell.PSConsoleReadLine]::ViEditVisually()
    # !! When we delegate to the PSReadline function, we cannot know whether the buffer was modified,
    # !! so we do NOT auto-submit it.
    # [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
    return
  }

  if ($needTempFile) {
    # Write the buffer to a temporary file.
    $tempFilePath = Join-Path ([IO.Path]::GetTempPath()) "psVisualEditTmp_$PID.ps1"    
    Set-Content -Encoding utf8 -LiteralPath $tempFilePath -Value $bufferText
    $lastWriteTimeUtc = [datetime]::UtcNow # Close enough.
  }

  # Edit the command using the chosen editor.
  if ($editorBaseName -eq 'micro') {
    # `micro` supports passing the cursor pos. as well as pipeline-based 
    # modal editing:
    # If its stdout output is redirected, saving the editor's buffer is neither
    # needed nor supported, and the potentially modified buffer content is output to stdout.
    $prevEnc = [Console]::OutputEncoding; [Console]::OutputEncoding = [Text.Utf8Encoding]::new()
    try {
      $newBufferText = ($bufferText | & $editor -tabstospaces on "+${lineNo}:${colNo}") -join "`n"
    }
    finally {
      [Console]::OutputEncoding = $prevEnc
    }
  }
  elseif ($editorBaseName -eq 'code') {
    # VSCode: make sure that the relevant options are used, and pass the cursor pos.
    if (-not $options) {
      $options = '--new-window', '--wait', '--goto'
    }
    $fileArg = if ($options[-1] -eq '--goto') { "${tempFilePath}:${lineNo}:${colNo}" } else { $tempFilePath }
    & $editor @options $fileArg
  }
  # elseif ($editorBaseName -eq 'mcedit') {
  #   !! We cannot handle `mcedit` (the editor that comes with `mc`, GNU's Midnight Commander) here,
  #   !! due to the stdout redirection problem. In principle, as of v4.8.31, its CLI suppors positioning the cursor 
  #   !! only by *line* number, with the cursor invariably placed in the *first column* of that line.
  #   !! e.g., `mcedit +5 foo.ps1`
  # }
  else {
    # All other (GUI) editors: just pass the temp. file's path. 
    # Note: Due to lack of a unified mechanism, passing the cursor pos. is NOT supported,
    #       and the cursor will be at the *start* of the buffer.
    & $editor @options $tempFilePath
  }

  if ($needTempFile) {
    # Get the edited content from the temporary file and join its lines
    # explicitly with "`n", because on Windows the CR chars. would cause problems.
    $newBufferText = (Get-Content -LiteralPath $tempFilePath) -join "`n"
    $lastWriteTimeUtcAfter = (Get-Item -LiteralPath $tempFilePath).LastWriteTimeUtc
    Remove-Item $tempFilePath # Clean up.
  }

  $bufferModified = if ($needTempFile) { $lastWriteTimeUtcAfter -gt $lastWriteTimeUtc }
                    else { $newBufferText -cne $bufferText }

  Write-Verbose "Buffer modified: $bufferModified"

  # If the buffer text was modified in the editor, replace the command-line buffer with it and submit it.
  # Otherwise, do nothing (except possibly reactive the terminal application below.)
  if ($bufferModified) {
    # Replace the current buffer text with the text from the file.
    [Microsoft.PowerShell.PSConsoleReadLine]::Replace(0, $bufferText.Length, $newBufferText)
    # As Bash does - but unlike the ViEditVisually function - automatically submit the command.
    [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
  }

  # macOS only: If a GUI editor was used, we must explicitly reactivate the terminal.
  if ($isGuiEditor -and $IsMacOS) {
    Write-Verbose "macOS: Reactivating terminal app."
    # !! On macOS, when VSCode is used (or another GUI editor, as opposed to a terminal-based editor), 
    # !! the terminal application typically does NOT reactivate after the editor window closes (if you either close the window only or if other windows are open), 
    # !! so even if the user doesn't manually reactivate another window, explicit reactivation of the terminal is necessary.
    # !! Unfortunately, this is fairly slow.
    $terminalAppName = $env:TERM_PROGRAM
    # Terminal.app reports itself as 'Apple_Terminal', so we have to change it to 'Terminal.app'
    # iTerm2 reports its actual app-bundle name, 'iTerm.app', in $env:TERM_PROGRAM, and we assume
    # that any other terminal emulators do the same - if not, reactivation will fail quietly.
    if ($terminalAppName -eq 'Apple_Terminal') { $terminalAppName = 'Terminal.app' }
    $PSNativeCommandArgumentPassing = 'Legacy'
    osascript -e "tell application \`"$terminalAppName\`" to activate" 2>$null
    # Note: 
    #  On Windows and Linux, reactivating the terminal is not strictly needed, because VSCode there doesn't have the wrong-window activation problem that it has on macOS.
    #  Conceivably, as a courtesy feature it could still be attempted, to allow users to switch to other windows while editing (e.g., for a web search),
    #  however:
    #    * On Windows, something like `(New-Object -ComObject WScript.Shell).AppActivate($PID)` would only work if AHK is running,
    #      or some other utility that allows non-foreground processes to steal the focus, and even then it would only work in `conhost.exe` windows, not also in 
    #      in Windows Terminal.
  }

}
237dmitry commented 3 weeks ago

but adding the handler seems to work without being in any specific mode.

I didn't know that this handler could work in Windows edit mode. Now the F4 key launches mcedit to edit the command line. This has both convenience and inconvenience. You cannot copy a string from a console window, but this eliminates the risk of accidentally pressing Escape and losing the entire script block.

UPD:

You cannot copy a string from a console window

You can copy strings from the console host with ^O, it opens a bash console but keeps the content that was there before the editor was called.

microsoft-github-policy-service[bot] commented 3 weeks ago

This issue has been marked as answered and has not had any activity for 1 day. It has been closed for housekeeping purposes.

microsoft-github-policy-service[bot] commented 3 weeks ago

📣 Hey @jimbobmcgee, how did we do? We would love to hear your feedback with the link below! 🗣️

🔗 https://aka.ms/PSRepoFeedback

Microsoft Forms
jimbobmcgee commented 2 weeks ago

@kilasuit

a profile script that you can access on any/all servers you work on

Implies that such a script is allowed to exist; that all servers I work on are happily interconnected and can access the same resources.

If only...

But I understand the notion that this could be easily included in a profile script.