diff --git a/.github/policies/resourceManagement.yml b/.github/policies/resourceManagement.yml
index f10d1216d..f6cff8991 100644
--- a/.github/policies/resourceManagement.yml
+++ b/.github/policies/resourceManagement.yml
@@ -286,5 +286,17 @@ configuration:
- addLabel:
label: 'Needs-Attention :wave:'
description:
+ - if:
+ - payloadType: Issues
+ - and:
+ - isOpen
+ - 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..19e6ed684 100644
--- a/.github/workflows/IssuePreTriage.yml
+++ b/.github/workflows/IssuePreTriage.yml
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v3
- name: do-work
run: |
diff --git a/MockPSConsole/MockPSConsole.csproj b/MockPSConsole/MockPSConsole.csproj
index e934b2446..73766831e 100644
--- a/MockPSConsole/MockPSConsole.csproj
+++ b/MockPSConsole/MockPSConsole.csproj
@@ -18,7 +18,7 @@
-
+
diff --git a/PSReadLine/Changes.txt b/PSReadLine/Changes.txt
index 27ccd6f6e..f28a71322 100644
--- a/PSReadLine/Changes.txt
+++ b/PSReadLine/Changes.txt
@@ -1,3 +1,15 @@
+### [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)
diff --git a/PSReadLine/Cmdlets.cs b/PSReadLine/Cmdlets.cs
index bc89cc1f9..222185602 100644
--- a/PSReadLine/Cmdlets.cs
+++ b/PSReadLine/Cmdlets.cs
@@ -142,7 +142,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?
@@ -502,6 +502,8 @@ public object ListPredictionTooltipColor
set => _listPredictionTooltipColor = VTColorUtils.AsEscapeSequence(value);
}
+ public bool TerminateOrphanedConsoleApps { get; set; }
+
internal string _defaultTokenColor;
internal string _commentColor;
internal string _keywordColor;
@@ -808,6 +810,14 @@ public PredictionViewStyle PredictionViewStyle
[Parameter]
public Hashtable Colors { get; set; }
+ [Parameter]
+ public SwitchParameter TerminateOrphanedConsoleApps
+ {
+ get => _terminateOrphanedConsoleApps.GetValueOrDefault();
+ set => _terminateOrphanedConsoleApps = value;
+ }
+ internal SwitchParameter? _terminateOrphanedConsoleApps;
+
[ExcludeFromCodeCoverage]
protected override void EndProcessing()
{
diff --git a/PSReadLine/KeyBindings.vi.cs b/PSReadLine/KeyBindings.vi.cs
index 9d051ec02..63dd4bfcd 100644
--- a/PSReadLine/KeyBindings.vi.cs
+++ b/PSReadLine/KeyBindings.vi.cs
@@ -44,6 +44,9 @@ internal static ConsoleColor AlternateBackground(ConsoleColor bg)
private static Dictionary _viChordCTable;
private static Dictionary _viChordYTable;
private static Dictionary _viChordDGTable;
+ private static Dictionary _viChordDQuoteTable;
+
+ private static Dictionary _viChordTextObjectsTable;
private static Dictionary> _viCmdChordTable;
private static Dictionary> _viInsChordTable;
@@ -216,6 +219,7 @@ private void SetDefaultViBindings()
{ Keys.Comma, MakeKeyHandler(RepeatLastCharSearchBackwards, "RepeatLastCharSearchBackwards") },
{ Keys.AltH, MakeKeyHandler(ShowParameterHelp, "ShowParameterHelp") },
{ Keys.F1, MakeKeyHandler(ShowCommandHelp, "ShowCommandHelp") },
+ { Keys.DQuote, MakeKeyHandler(ViChord, "ChordFirstKey") },
};
// Some bindings are not available on certain platforms
@@ -238,6 +242,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") },
@@ -296,11 +301,23 @@ private void SetDefaultViBindings()
{ Keys.Percent, MakeKeyHandler( ViYankPercent, "ViYankPercent") },
};
+ _viChordTextObjectsTable = new Dictionary
+ {
+ { Keys.W, MakeKeyHandler(ViHandleTextObject, "WordTextObject")},
+ };
+
_viChordDGTable = new Dictionary
{
{ Keys.G, MakeKeyHandler( DeleteRelativeLines, "DeleteRelativeLines") },
};
+ _viChordDQuoteTable = new Dictionary
+ {
+ { Keys.DQuote, MakeKeyHandler( ViSelectNamedRegister, "ViSelectNamedRegister" ) },
+ { Keys.Plus, MakeKeyHandler( ViSelectNamedRegister, "ViSelectNamedRegister" ) },
+ { Keys.Underbar, MakeKeyHandler( ViSelectNamedRegister, "ViSelectNamedRegister" ) },
+ };
+
_viCmdChordTable = new Dictionary>();
_viInsChordTable = new Dictionary>();
@@ -310,6 +327,7 @@ private void SetDefaultViBindings()
_viCmdChordTable[Keys.D] = _viChordDTable;
_viCmdChordTable[Keys.C] = _viChordCTable;
_viCmdChordTable[Keys.Y] = _viChordYTable;
+ _viCmdChordTable[Keys.DQuote] = _viChordDQuoteTable;
_normalCursorSize = _console.CursorSize;
if ((_normalCursorSize < 1) || (_normalCursorSize > 100))
diff --git a/PSReadLine/Options.cs b/PSReadLine/Options.cs
index 19f366513..7485154b4 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,22 @@ 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)));
+ }
+ }
}
private void SetKeyHandlerInternal(string[] keys, Action handler, string briefDescription, string longDescription, ScriptBlock scriptBlock)
diff --git a/PSReadLine/PSReadLine.csproj b/PSReadLine/PSReadLine.csproj
index abe887a7a..38b8f6430 100644
--- a/PSReadLine/PSReadLine.csproj
+++ b/PSReadLine/PSReadLine.csproj
@@ -5,9 +5,9 @@
Microsoft.PowerShell.PSReadLineMicrosoft.PowerShell.PSReadLine2$(NoWarn);CA1416
- 2.3.1.0
- 2.3.1
- 2.3.1-beta1
+ 2.3.2.0
+ 2.3.2
+ 2.3.2-beta2truenet462;net6.0true
@@ -22,7 +22,7 @@
-
+
diff --git a/PSReadLine/PSReadLine.format.ps1xml b/PSReadLine/PSReadLine.format.ps1xml
index 5b20c58af..24f70799a 100644
--- a/PSReadLine/PSReadLine.format.ps1xml
+++ b/PSReadLine/PSReadLine.format.ps1xml
@@ -164,6 +164,9 @@ $d = [Microsoft.PowerShell.KeyHandler]::GetGroupingDescription($_.Group)
PredictionViewStyle
+
+ TerminateOrphanedConsoleApps
+ [Microsoft.PowerShell.VTColorUtils]::FormatColor($_.CommandColor)
diff --git a/PSReadLine/PSReadLine.psd1 b/PSReadLine/PSReadLine.psd1
index 11299abf0..3f900233a 100644
--- a/PSReadLine/PSReadLine.psd1
+++ b/PSReadLine/PSReadLine.psd1
@@ -1,7 +1,7 @@
@{
RootModule = 'PSReadLine.psm1'
NestedModules = @("Microsoft.PowerShell.PSReadLine2.dll")
-ModuleVersion = '2.3.1'
+ModuleVersion = '2.3.2'
GUID = '5714753b-2afd-4492-a5fd-01d9e2cff8b5'
Author = 'Microsoft Corporation'
CompanyName = 'Microsoft Corporation'
diff --git a/PSReadLine/PSReadLineResources.Designer.cs b/PSReadLine/PSReadLineResources.Designer.cs
index 7d3435945..ac672d3f7 100644
--- a/PSReadLine/PSReadLineResources.Designer.cs
+++ b/PSReadLine/PSReadLineResources.Designer.cs
@@ -2257,5 +2257,16 @@ internal static string UpcaseWordDescription
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 618ef22b9..e619e7c06 100644
--- a/PSReadLine/PSReadLineResources.resx
+++ b/PSReadLine/PSReadLineResources.resx
@@ -873,4 +873,7 @@ Or not saving history with:
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..86b4a73c5 100644
--- a/PSReadLine/PlatformWindows.cs
+++ b/PSReadLine/PlatformWindows.cs
@@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
+using System.Linq;
using System.Runtime.InteropServices;
using Microsoft.PowerShell;
using Microsoft.PowerShell.Internal;
@@ -201,6 +202,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 +299,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 +315,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 +334,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 +612,410 @@ 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.)
+ PROCESS_BASIC_INFORMATION pbi;
+ int size;
+ var res = NtQueryInformationProcess(process.Handle, 0, out pbi, Marshal.SizeOf(), out size);
+
+ 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);
+ }
+ }
+ }
}
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.Views.cs b/PSReadLine/Prediction.Views.cs
index a2770eca4..a9145c432 100644
--- a/PSReadLine/Prediction.Views.cs
+++ b/PSReadLine/Prediction.Views.cs
@@ -1513,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;
}
@@ -1529,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/ReadLine.cs b/PSReadLine/ReadLine.cs
index 3b042a448..0a3df975f 100644
--- a/PSReadLine/ReadLine.cs
+++ b/PSReadLine/ReadLine.cs
@@ -210,7 +210,7 @@ internal static PSKeyInfo ReadKey()
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)
@@ -651,7 +651,21 @@ void ProcessOneKey(PSKeyInfo key, Dictionary dispatchTabl
static PSConsoleReadLine()
{
_singleton = new PSConsoleReadLine();
- _viRegister = new ViRegister(_singleton);
+
+ _registers = new Dictionary {
+ [""] = new ViRegister(_singleton, new InMemoryClipboard()),
+ ["_"] = new ViRegister(_singleton, new NoOpClipboard()),
+ ["\""] = new ViRegister(_singleton, new SystemClipboard()),
+ };
+
+ // '+' and '"' are synonyms
+
+ _registers["+"] = _registers["\""];
+
+ // default register is the unnamed local register
+
+ _viRegister = _registers[""];
+
InitializePropertyInfo();
}
diff --git a/PSReadLine/ReadLine.vi.cs b/PSReadLine/ReadLine.vi.cs
index c39932c75..5f202c02f 100644
--- a/PSReadLine/ReadLine.vi.cs
+++ b/PSReadLine/ReadLine.vi.cs
@@ -445,6 +445,7 @@ public static void ViCommandMode(ConsoleKeyInfo? key = null, object arg = null)
_singleton._dispatchTable = _viCmdKeyMap;
_singleton._chordDispatchTable = _viCmdChordTable;
ViBackwardChar();
+ ViSelectNamedRegister("");
_singleton.ViIndicateCommandMode();
}
@@ -746,7 +747,7 @@ private static int DeleteLineImpl(int lineIndex, int lineCount)
var deleteText = _singleton._buffer.ToString(range.Offset, range.Count);
- _viRegister.LinewiseRecord(deleteText);
+ _singleton.SaveLinesToClipboard(deleteText);
var deletePosition = range.Offset;
var anchor = _singleton._current;
@@ -1150,6 +1151,16 @@ private static void ViDGChord(ConsoleKeyInfo? key = null, object arg = null)
ViChordHandler(_viChordDGTable, arg);
}
+ private static void ViDQuoteChord(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ if (!key.HasValue)
+ {
+ throw new ArgumentNullException(nameof(key));
+ }
+
+ ViChordHandler(_viChordDQuoteTable, arg);
+ }
+
private static bool IsNumeric(PSKeyInfo key)
{
return key.KeyChar >= '0' && key.KeyChar <= '9' && !key.Control && !key.Alt;
diff --git a/PSReadLine/SamplePSReadLineProfile.ps1 b/PSReadLine/SamplePSReadLineProfile.ps1
index 69177499b..0956dc2f3 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' `
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/ViRegister.cs b/PSReadLine/ViRegister.cs
index 48df9965f..29940aea3 100644
--- a/PSReadLine/ViRegister.cs
+++ b/PSReadLine/ViRegister.cs
@@ -5,31 +5,93 @@ namespace Microsoft.PowerShell
{
public partial class PSConsoleReadLine
{
+ ///
+ /// Represents a clipboard that holds a piece of text.
+ ///
+ internal interface IClipboard
+ {
+ ///
+ /// Retrieves the text stored in the clipboard.
+ ///
+ string GetText();
+ ///
+ /// Stores some text in the clipboard.
+ ///
+ ///
+ void SetText(string text);
+ }
+
+ ///
+ /// Represents an in-memory clipboard.
+ ///
+ internal sealed class InMemoryClipboard : IClipboard
+ {
+ private string _text;
+ public string GetText()
+ => _text ?? "";
+
+ public void SetText(string text)
+ => _text = text;
+ }
+
+ ///
+ /// Represents a clipboard that does not store any text.
+ ///
+ internal sealed class NoOpClipboard : IClipboard
+ {
+ public string GetText() => "";
+ public void SetText(string text) { }
+ }
+
+ ///
+ /// Represents the system clipboard.
+ ///
+ internal sealed class SystemClipboard : IClipboard
+ {
+ public string GetText()
+ => Internal.Clipboard.GetText();
+
+ public void SetText(string text)
+ => Internal.Clipboard.SetText(text);
+ }
+
///
/// Represents a named register.
///
internal sealed class ViRegister
{
private readonly PSConsoleReadLine _singleton;
- private string _text;
+ private readonly IClipboard _clipboard;
- ///
/// Initialize a new instance of the class.
///
/// The object.
/// Used to hook into the undo / redo subsystem as part of
/// pasting the contents of the register into a buffer.
///
- public ViRegister(PSConsoleReadLine singleton)
+ /// The clipboard to store text to and retrieve text from
+ public ViRegister(PSConsoleReadLine singleton, IClipboard clipboard)
{
+ _clipboard = clipboard;
_singleton = singleton;
}
+ /// Initialize a new instance of the class.
+ ///
+ /// The object.
+ /// Used to hook into the undo / redo subsystem as part of
+ /// pasting the contents of the register into a buffer.
+ ///
+ public ViRegister(PSConsoleReadLine singleton)
+ : this(singleton, new InMemoryClipboard())
+ {
+ }
+
///
/// Returns whether this register is empty.
///
public bool IsEmpty
- => String.IsNullOrEmpty(_text);
+ => String.IsNullOrEmpty(_clipboard.GetText());
///
/// Returns whether this register contains
@@ -41,16 +103,14 @@ public bool IsEmpty
/// Gets the raw text contained in the register
///
public string RawText
- => _text;
+ => _clipboard.GetText();
///
/// Records the entire buffer in the register.
///
///
public void Record(StringBuilder buffer)
- {
- Record(buffer, 0, buffer.Length);
- }
+ => Record(buffer, 0, buffer.Length);
///
/// Records a piece of text in the register.
@@ -67,7 +127,7 @@ public void Record(StringBuilder buffer, int offset, int count)
System.Diagnostics.Debug.Assert(offset + count <= buffer.Length);
HasLinewiseText = false;
- _text = buffer.ToString(offset, count);
+ _clipboard.SetText(buffer.ToString(offset, count));
}
///
@@ -77,7 +137,7 @@ public void Record(StringBuilder buffer, int offset, int count)
public void LinewiseRecord(string text)
{
HasLinewiseText = true;
- _text = text;
+ _clipboard.SetText(text);
}
public int PasteAfter(StringBuilder buffer, int position)
@@ -87,9 +147,11 @@ public int PasteAfter(StringBuilder buffer, int position)
return position;
}
+ var yanked = _clipboard.GetText();
+
if (HasLinewiseText)
{
- var text = _text;
+ var text = yanked;
if (text[0] != '\n')
{
@@ -99,7 +161,6 @@ public int PasteAfter(StringBuilder buffer, int position)
// paste text after the next line
var pastePosition = -1;
- var newCursorPosition = position;
for (var index = position; index < buffer.Length; index++)
{
@@ -127,8 +188,8 @@ public int PasteAfter(StringBuilder buffer, int position)
position += 1;
}
- InsertAt(buffer, _text, position, position);
- position += _text.Length - 1;
+ InsertAt(buffer, yanked, position, position);
+ position += yanked.Length - 1;
return position;
}
@@ -136,6 +197,8 @@ public int PasteAfter(StringBuilder buffer, int position)
public int PasteBefore(StringBuilder buffer, int position)
{
+ var yanked = _clipboard.GetText();
+
if (HasLinewiseText)
{
// currently, in Vi Edit Mode, the cursor may be positioned
@@ -145,7 +208,7 @@ public int PasteBefore(StringBuilder buffer, int position)
position = Math.Max(0, Math.Min(position, buffer.Length - 1));
- var text = _text;
+ var text = yanked;
if (text[0] == '\n')
{
@@ -184,8 +247,8 @@ public int PasteBefore(StringBuilder buffer, int position)
}
else
{
- InsertAt(buffer, _text, position, position);
- return position + _text.Length - 1;
+ InsertAt(buffer, yanked, position, position);
+ return position + yanked.Length - 1;
}
}
@@ -246,7 +309,7 @@ private void RecordPaste(string text, int position, int anchor)
#if DEBUG
public override string ToString()
{
- var text = _text.Replace("\n", "\\n");
+ var text = _clipboard.GetText().Replace("\n", "\\n");
return (HasLinewiseText ? "line: " : "") + "\"" + text + "\"";
}
#endif
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 d59fdd5cc..7fc261176 100644
--- a/PSReadLine/YankPaste.vi.cs
+++ b/PSReadLine/YankPaste.vi.cs
@@ -3,7 +3,7 @@
--********************************************************************/
using System;
-using System.Text;
+using System.Collections.Generic;
namespace Microsoft.PowerShell
{
@@ -12,7 +12,12 @@ public partial class PSConsoleReadLine
// *must* be initialized in the static ctor
// because it depends on static member _singleton
// being initialized first.
- private static readonly ViRegister _viRegister;
+ private static readonly IDictionary _registers;
+
+ ///
+ /// The currently selected object.
+ ///
+ private static ViRegister _viRegister;
///
/// Paste the clipboard after the cursor, moving the cursor to the end of the pasted text.
@@ -44,18 +49,21 @@ public static void PasteBefore(ConsoleKeyInfo? key = null, object arg = null)
private void PasteAfterImpl()
{
_current = _viRegister.PasteAfter(_buffer, _current);
+ ViSelectNamedRegister("");
Render();
}
private void PasteBeforeImpl()
{
_current = _viRegister.PasteBefore(_buffer, _current);
+ ViSelectNamedRegister("");
Render();
}
private void SaveToClipboard(int startIndex, int length)
{
_viRegister.Record(_buffer, startIndex, length);
+ ViSelectNamedRegister("");
}
///
@@ -67,7 +75,17 @@ private void SaveToClipboard(int startIndex, int length)
private void SaveLinesToClipboard(int lineIndex, int lineCount)
{
var range = _buffer.GetRange(lineIndex, lineCount);
- _viRegister.LinewiseRecord(_buffer.ToString(range.Offset, range.Count));
+ SaveLinesToClipboard(_buffer.ToString(range.Offset, range.Count));
+ }
+
+ ///
+ /// Save the specified text as a linewise selection.
+ ///
+ ///
+ private void SaveLinesToClipboard(string text)
+ {
+ _viRegister.LinewiseRecord(text);
+ ViSelectNamedRegister("");
}
///
@@ -366,5 +384,39 @@ public static void ViYankNextGlob(ConsoleKeyInfo? key = null, object arg = null)
}
_singleton.SaveToClipboard(_singleton._current, end - _singleton._current);
}
+
+ ///
+ /// Selects one the availabled named register for
+ /// subsequent yank/paste operations.
+ /// PSReadLine supports the following named registers:
+ ///
+ /// - "_" (underscore): void register
+ ///
+ /// In addition, PSReadLine supports an internal in-memory
+ /// unnamed register which is selected by default for all
+ /// yank/paste operations unless specifically overridden.
+ ///
+ ///
+ ///
+ ///
+ public static void ViSelectNamedRegister(ConsoleKeyInfo? key = null, object arg = null)
+ {
+ if (key != null)
+ {
+ var name = key.Value.KeyChar.ToString();
+ ViSelectNamedRegister(name);
+ }
+ }
+
+ private static void ViSelectNamedRegister(string name)
+ {
+ if (!_registers.ContainsKey(name))
+ {
+ Ding();
+ return;
+ }
+
+ _viRegister = _registers[name];
+ }
}
}
diff --git a/Polyfill/Polyfill.csproj b/Polyfill/Polyfill.csproj
index c6b7ee481..bcc2fcb24 100644
--- a/Polyfill/Polyfill.csproj
+++ b/Polyfill/Polyfill.csproj
@@ -12,7 +12,7 @@
-
+
diff --git a/test/PSReadLine.Tests.csproj b/test/PSReadLine.Tests.csproj
index e79fa34e8..b6c4625f0 100644
--- a/test/PSReadLine.Tests.csproj
+++ b/test/PSReadLine.Tests.csproj
@@ -19,12 +19,12 @@
-
+
-
+
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..f819b3879
--- /dev/null
+++ b/test/TextObjects.Vi.Tests.cs
@@ -0,0 +1,176 @@
+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);
+
+ var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length;
+
+ 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);
+
+ var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length;
+
+ 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);
+
+ var continuationPrefixLength = PSConsoleReadLineOptions.DefaultContinuationPrompt.Length;
+
+ 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/YankPasteTest.VI.cs b/test/YankPasteTest.VI.cs
index 590f04be1..d155069a3 100644
--- a/test/YankPasteTest.VI.cs
+++ b/test/YankPasteTest.VI.cs
@@ -584,5 +584,18 @@ public void ViDeleteAndPasteLogicalLines_EmptyBuffer()
"2dd", 'P', CheckThat(() => AssertCursorLeftTopIs(0, 0))
));
}
+
+ [SkippableFact]
+ public void ViYankToBlackHoleRegister()
+ {
+ TestSetup(KeyMode.Vi);
+
+ Test("text\n", Keys(
+ "text", _.Escape, "dd",
+ "iline", _.Escape,
+ "\"_dd", CheckThat(() => AssertLineIs("")),
+ "P", CheckThat(() => AssertLineIs("text\n"))
+ ));
+ }
}
}