Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
jimbobmcgee opened this issue Apr 24, 2024 · 11 comments
Closed

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

jimbobmcgee opened this issue Apr 24, 2024 · 11 comments
Labels
Issue-Question ideally support can be provided via other mechanisms, but sometimes folks do open an issue to get a Resolution-Answered The question is answered.

Comments

@jimbobmcgee
Copy link

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

@jimbobmcgee jimbobmcgee added Issue-Enhancement the issue is more of a feature request than a bug Needs-Triage The issue is new and needs to be triaged by a work group. labels Apr 24, 2024
@rhubarb-geek-nz
Copy link

I suggest this would be a PSReadLine feature.

@MartinGC94
Copy link
Contributor

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
Copy link

237dmitry commented Apr 24, 2024

You can use script block to edit command

PS > & {
          edit your command inside this unnamed function
       }

@SeeminglyScience
Copy link
Collaborator

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.

@SeeminglyScience SeeminglyScience added Issue-Question ideally support can be provided via other mechanisms, but sometimes folks do open an issue to get a Resolution-Answered The question is answered. and removed Issue-Enhancement the issue is more of a feature request than a bug Needs-Triage The issue is new and needs to be triaged by a work group. labels Apr 24, 2024
@jimbobmcgee
Copy link
Author

@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
Copy link
Collaborator

@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
Copy link
Contributor

mklement0 commented Apr 25, 2024

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

tl;dr:

  • The bottom section of this comment contains a custom implementation of ViEditVisually that improves on the latter in several ways and is easy to customize.

  • The following section discusses ViEditVisually current limitations.


  • ViEditVisually checks $env:VISUAL first, then $env:EDITOR (in-session changes are honored).

  • The default key bindings are:

    • By PSReadLine edit mode (get / set it with (Get-PSReadLineOption).EditMode / Set-PSReadLineOption -EditMode $newMode, with $newMode being one of Windows, Emacs, Vi):
      • Windows: none:
      • Emacs: Ctrl-x, Ctrl-e
      • Vi: Esc, v
    • By platform (OS), which is implied by each platform's default edit mode:
      • Windows (defaults to Windows): none:
      • Linux and macOS (defaults to Emacs): Ctrl-x, Ctrl-e
      • However, you're free to change the edit mode on any platform, and to define your own key binding via a $PROFILE file, as previously shown and noted.
  • Limitations and behavioral notes:

    • A fundamental limitation as of this writing is that the $env:VISUAL / $env:EDITOR value may be a mere executable name or path only; that is, you cannot "bake in" options; for instance $env:VISUAL='code' works fine, but $env:VISUAL='code --new-window --wait' does not - and the latter options are the prerequisite for making code (the Visual Studio Code CLI) suitable for use with ViEditVisually. (The function fundamentally makes no attempt to invoke GUI editors synchronously, so that $env:VISUAL = 'Notepad' also doesn't work, for instance; in short: you need a console-based CLI that synchronously invokes a dedicated editor for the file path it is passed.)

    • Another fundamental limitation as of this writing is that $env:VISUAL / $env:EDITOR must refer to an external (native) program, which precludes use of cmdlet-based console editors such as the aforementioned psedit aka Show-PSEditor; the latter is notable for its support for syntax highlighting and error analysis, tab-completion and reformatting.

    • Due to lack of a unified mechanism in external editors for controlling the cursor position on startup, the cursor will be at the start of the edit buffer, irrespective where the cursor was on the command line at the time of invocation of ViEditVisually.

    • ViEditVisually passes the current command-line buffer via a temporary file('s path) to the configured editor, and uses the potentially modified content of that file as the result.

      • The upshot is that you must ensure that the temporary file is saved before or in the context of exiting the editor, which usually requires an explicit additional step, unless the editor is configured to auto-save.

      • Again, there's no unified mechanism for such modal editing interactions, but the aforementioned micro editor offers a superior mechanism: when its stdout stream is redirected, it modifies its behavior to not require saving, and to output the buffer contents (which may originally have been received via stdin) via stdout.

    • Unlike the equivalent Bash readline function, which automatically submits the command "returned" from the editor, ViEditVisually merely replaces the current command-line buffer with it, requiring Enter to submit it.


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:

  • It uses key binding Alt+e for invocation, which is cross-platform-friendly; adjust as needed.

  • If neither $env:VISUAL nor $env:EDITOR is defined, it uses code (Visual Studio Code) by default, if present, with additional fallbacks, including micro and several others; adjust as needed.

  • It supports specifying PowerShell commands via $env:VISUAL nor $env:EDITOR, so that Show-PSEditor may be used, for instance, though the latter appears to be rough around the edges as of this writing (see below).

  • It knows how to invoke code and micro with the appropriate options, which includes positioning the cursor in the editor's buffer in the same place as in the command-line buffer on invocation.

  • It auto-submits the edited buffer, if saved (if not saved, no action is taken, i.e. the original command-line buffer is retained and nothing is executed). To change that, simply comment out the two [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() lines.

    • However, it only does so for (presumed) GUI editors such as code, for Show-PSEditor, and - as an exception among terminal-based editors, micro.
    • For technical reasons, most terminal-based editors, e.g, nano cannot be invoked from a -ScriptBlock argument, because PSReadLine seemingly redirects (captures) stdout. In that event, the standard ViEditVisually is invoked instead, and - due to lack of knowledge whether or not the buffer was modified - the result is not auto-submitted; also, the command-line cursor position cannot be preserved in that case.
    • You may set $VerbosePreference = 'Continue' to see behind-the-scenes details about the invocation.

In terms of UX:

  • code provides a rich PowerShell experience, but invoking it, which involves loading the PowerShell extension, is slow, and the fact that a different application is activated is visually disruptive. Also, on macOS a workaround is needed to re-activate the calling terminal. Explicit saving of the temporary file is needed, though the Files: Auto Save setting may be set to onFocusChange to make closing the window with Ctrl+W sufficient; however, note that auto-saving is then performed invariably and that the setting applies to all future windows, which may not be desired.

  • micro, as a terminal-based editor, minimizes the visual disruption and loads quickly, and is invoked in a manner that (invariably) auto-saves the result when pressing Ctrl+Q; while this makes for the most seamless integration overall, micro lacks PowerShell-specific features other than syntax highlighting.

  • Show-PSEditor has great potential, but as of this writing is plagued by several bugs and one notable usability issue: you must explicitly save with Ctrl+S; pressing just Ctrl+Q quietly discards the changes. Also, Ctrl+S doesn't seem to work in Windows Terminal. Finally, it lacks support for positioning the cursor.

# 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
Copy link

237dmitry commented Apr 25, 2024

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.

Copy link
Contributor

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

Copy link
Contributor

microsoft-github-policy-service bot commented Apr 27, 2024

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

🔗 https://aka.ms/PSRepoFeedback

@jimbobmcgee
Copy link
Author

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Issue-Question ideally support can be provided via other mechanisms, but sometimes folks do open an issue to get a Resolution-Answered The question is answered.
Projects
None yet
Development

No branches or pull requests

7 participants