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.PSReadLine Microsoft.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-beta2 true net462;net6.0 true @@ -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")) + )); + } } }