diff --git a/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs new file mode 100644 index 0000000..069edb6 --- /dev/null +++ b/CosmosDBShell.Tests/Shell/HotkeyCommandTests.cs @@ -0,0 +1,590 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// ------------------------------------------------------------ + +namespace CosmosShell.Tests.Shell; + +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.KeyBindings; +using RadLine; +using Spectre.Console; + +public class HotkeyCommandTests +{ + [Fact] + public void MoveToStartOfLine_PlacesCursorAtZero() + { + var context = CreateContext("hello world", position: 7); + + new MoveToStartOfLineCommand().Execute(context); + + Assert.Equal(0, context.Buffer.Position); + Assert.Equal("hello world", context.Buffer.Content); + } + + [Fact] + public void MoveToEndOfLine_PlacesCursorAtLength() + { + var context = CreateContext("hello world", position: 3); + + new MoveToEndOfLineCommand().Execute(context); + + Assert.Equal(context.Buffer.Length, context.Buffer.Position); + Assert.Equal("hello world", context.Buffer.Content); + } + + [Fact] + public void DeleteToStartOfLine_RemovesTextBeforeCursor() + { + var context = CreateContext("hello world", position: 6); + + new DeleteToStartOfLineCommand().Execute(context); + + Assert.Equal("world", context.Buffer.Content); + Assert.Equal(0, context.Buffer.Position); + } + + [Fact] + public void DeleteToStartOfLine_AtBeginning_NoOp() + { + var context = CreateContext("hello", position: 0); + + new DeleteToStartOfLineCommand().Execute(context); + + Assert.Equal("hello", context.Buffer.Content); + Assert.Equal(0, context.Buffer.Position); + } + + [Fact] + public void DeleteToEndOfLine_RemovesTextAfterCursor() + { + var context = CreateContext("hello world", position: 5); + + new DeleteToEndOfLineCommand().Execute(context); + + Assert.Equal("hello", context.Buffer.Content); + Assert.Equal(5, context.Buffer.Position); + } + + [Fact] + public void DeleteToEndOfLine_AtEnd_NoOp() + { + var context = CreateContext("hello", position: 5); + + new DeleteToEndOfLineCommand().Execute(context); + + Assert.Equal("hello", context.Buffer.Content); + Assert.Equal(5, context.Buffer.Position); + } + + [Fact] + public void DeletePreviousWord_RemovesLastWordBeforeCursor() + { + var context = CreateContext("hello world", position: 11); + + new DeletePreviousWordCommand().Execute(context); + + Assert.Equal("hello ", context.Buffer.Content); + Assert.Equal(6, context.Buffer.Position); + } + + [Fact] + public void DeletePreviousWord_SkipsTrailingWhitespace() + { + var context = CreateContext("hello world ", position: 14); + + new DeletePreviousWordCommand().Execute(context); + + Assert.Equal("hello ", context.Buffer.Content); + Assert.Equal(6, context.Buffer.Position); + } + + [Fact] + public void DeletePreviousWord_OnlyDeletesUpToCursor() + { + var context = CreateContext("hello world today", position: 11); + + new DeletePreviousWordCommand().Execute(context); + + Assert.Equal("hello today", context.Buffer.Content); + Assert.Equal(6, context.Buffer.Position); + } + + [Fact] + public void DeletePreviousWord_AtBeginning_NoOp() + { + var context = CreateContext("hello", position: 0); + + new DeletePreviousWordCommand().Execute(context); + + Assert.Equal("hello", context.Buffer.Content); + Assert.Equal(0, context.Buffer.Position); + } + + [Fact] + public void ExitShell_EmptyBuffer_StopsShell() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.IsRunning = true; + var context = CreateContext(string.Empty, position: 0); + + new ExitShellCommand(shell).Execute(context); + + Assert.False(shell.IsRunning); + } + + [Fact] + public void ExitShell_NonEmptyBuffer_DeletesCharAtCursor() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.IsRunning = true; + var context = CreateContext("hello", position: 1); + + new ExitShellCommand(shell).Execute(context); + + Assert.True(shell.IsRunning); + Assert.Equal("hllo", context.Buffer.Content); + Assert.Equal(1, context.Buffer.Position); + } + + [Fact] + public void ExitShell_NonEmptyBufferAtEnd_NoOp() + { + using var shell = ShellInterpreter.CreateInstance(); + shell.IsRunning = true; + var context = CreateContext("hello", position: 5); + + new ExitShellCommand(shell).Execute(context); + + Assert.True(shell.IsRunning); + Assert.Equal("hello", context.Buffer.Content); + } + + [Fact] + public void MoveCursorLeft_DecrementsPosition() + { + var context = CreateContext("hello", position: 3); + + new MoveCursorLeftCommand().Execute(context); + + Assert.Equal(2, context.Buffer.Position); + } + + [Fact] + public void MoveCursorLeft_AtBeginning_NoOp() + { + var context = CreateContext("hello", position: 0); + + new MoveCursorLeftCommand().Execute(context); + + Assert.Equal(0, context.Buffer.Position); + } + + [Fact] + public void MoveCursorRight_IncrementsPosition() + { + var context = CreateContext("hello", position: 3); + + new MoveCursorRightCommand().Execute(context); + + Assert.Equal(4, context.Buffer.Position); + } + + [Fact] + public void MoveCursorRight_AtEnd_NoOp() + { + var context = CreateContext("hello", position: 5); + + new MoveCursorRightCommand().Execute(context); + + Assert.Equal(5, context.Buffer.Position); + } + + [Fact] + public void ReverseSearch_FindsNewestSubstringMatch() + { + var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" }; + + var index = ReverseHistorySearch.FindReverseMatch(history, "query", 0); + + Assert.Equal(3, index); + } + + [Fact] + public void ReverseSearch_SkipsToOlderMatch() + { + var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" }; + + var index = ReverseHistorySearch.FindReverseMatch(history, "query", 1); + + Assert.Equal(1, index); + } + + [Fact] + public void ReverseSearch_NoMoreMatches_ReturnsMinusOne() + { + var history = new[] { "ls", "query foo" }; + + var index = ReverseHistorySearch.FindReverseMatch(history, "query", 1); + + Assert.Equal(-1, index); + } + + [Fact] + public void ReverseSearch_FindNextReverseMatch_AdvancesToOlderMatch() + { + var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" }; + + var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 0); + + Assert.True(result.HasMatch); + Assert.Equal(1, result.Index); + Assert.Equal(1, result.Skip); + Assert.Equal("query SELECT * FROM c", result.Match); + } + + [Fact] + public void ReverseSearch_FindNextReverseMatch_WrapsToNewestMatch() + { + var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" }; + + var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 1); + + Assert.True(result.HasMatch); + Assert.Equal(3, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal("query foo", result.Match); + } + + [Fact] + public void ReverseSearch_FindNextReverseMatch_NoMatches_ReturnsMinusOne() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 0); + + Assert.False(result.HasMatch); + Assert.Equal(-1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal(string.Empty, result.Match); + } + + [Fact] + public void ReverseSearch_FindInitialMatch_ReturnsNewestMatchResult() + { + var history = new[] { "query one", "ls", "query two" }; + + var result = ReverseHistorySearch.FindInitialMatch(history, "query"); + + Assert.True(result.HasMatch); + Assert.Equal(2, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal("query two", result.Match); + } + + [Fact] + public void ReverseSearch_FindInitialMatch_NoMatch_ReturnsEmptyResult() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindInitialMatch(history, "query"); + + Assert.False(result.HasMatch); + Assert.Equal(-1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal(string.Empty, result.Match); + } + + [Fact] + public void ReverseSearch_FindInitialForwardMatch_ReturnsOldestMatchResult() + { + var history = new[] { "query one", "ls", "query two" }; + + var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query"); + + Assert.True(result.HasMatch); + Assert.Equal(0, result.Index); + Assert.Equal(1, result.Skip); + Assert.Equal("query one", result.Match); + } + + [Fact] + public void ReverseSearch_FindInitialForwardMatch_NoMatch_ReturnsEmptyResult() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindInitialForwardMatch(history, "query"); + + Assert.False(result.HasMatch); + Assert.Equal(-1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal(string.Empty, result.Match); + } + + [Fact] + public void ReverseSearch_FindNextMatch_SingleMatch_WrapsToSameResult() + { + var history = new[] { "ls", "query foo" }; + + var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 0); + + Assert.True(result.HasMatch); + Assert.Equal(1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal("query foo", result.Match); + } + + [Fact] + public void ReverseSearch_FindNextMatch_CurrentSkipPastEnd_WrapsToNewestMatch() + { + var history = new[] { "query one", "query two" }; + + var result = ReverseHistorySearch.FindNextMatch(history, "query", currentSkip: 10); + + Assert.True(result.HasMatch); + Assert.Equal(1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal("query two", result.Match); + } + + [Fact] + public void ReverseSearch_FindPreviousMatch_AdvancesToNewerMatch() + { + var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" }; + + var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 1); + + Assert.True(result.HasMatch); + Assert.Equal(3, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal("query foo", result.Match); + } + + [Fact] + public void ReverseSearch_FindPreviousMatch_WrapsToOldestMatch() + { + var history = new[] { "ls", "query SELECT * FROM c", "echo hi", "query foo" }; + + var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0); + + Assert.True(result.HasMatch); + Assert.Equal(1, result.Index); + Assert.Equal(1, result.Skip); + Assert.Equal("query SELECT * FROM c", result.Match); + } + + [Fact] + public void ReverseSearch_FindPreviousMatch_SingleMatch_WrapsToSameResult() + { + var history = new[] { "ls", "query foo" }; + + var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0); + + Assert.True(result.HasMatch); + Assert.Equal(1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal("query foo", result.Match); + } + + [Fact] + public void ReverseSearch_FindPreviousMatch_NoMatches_ReturnsEmptyResult() + { + var history = new[] { "ls", "echo hi" }; + + var result = ReverseHistorySearch.FindPreviousMatch(history, "query", currentSkip: 0); + + Assert.False(result.HasMatch); + Assert.Equal(-1, result.Index); + Assert.Equal(0, result.Skip); + Assert.Equal(string.Empty, result.Match); + } + + [Fact] + public void ReverseSearch_IsCaseInsensitive() + { + var history = new[] { "QUERY foo" }; + + var index = ReverseHistorySearch.FindReverseMatch(history, "query", 0); + + Assert.Equal(0, index); + } + + [Fact] + public void ReverseSearch_EmptyQuery_ReturnsMinusOne() + { + var history = new[] { "ls", "query foo" }; + + var index = ReverseHistorySearch.FindReverseMatch(history, string.Empty, 0); + + Assert.Equal(-1, index); + } + + [Fact] + public void ReverseSearch_NoMatches_ReturnsMinusOne() + { + var history = new[] { "ls", "echo hi" }; + + var index = ReverseHistorySearch.FindReverseMatch(history, "query", 0); + + Assert.Equal(-1, index); + } + + [Fact] + public void ReverseSearch_FormatSearchPrompt_WithMatch_UsesNormalState() + { + var prompt = ReverseHistorySearch.FormatSearchPrompt("que", "query foo", hasMatch: true); + + Assert.Equal("(reverse-i-search)`que`: query foo", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPrompt_WithoutMatch_UsesFailedState() + { + var prompt = ReverseHistorySearch.FormatSearchPrompt("missing", string.Empty, hasMatch: false); + + Assert.Equal("(failed reverse-i-search)`missing`: ", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPrompt_EmptyQuery_UsesNormalState() + { + var prompt = ReverseHistorySearch.FormatSearchPrompt(string.Empty, string.Empty, hasMatch: false); + + Assert.Equal("(reverse-i-search)``: ", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPrompt_ForwardSearch_UsesForwardState() + { + var prompt = ReverseHistorySearch.FormatSearchPrompt("que", "query foo", hasMatch: true, isForwardSearch: true); + + Assert.Equal("(forward-i-search)`que`: query foo", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPrompt_ForwardSearchWithoutMatch_UsesFailedForwardState() + { + var prompt = ReverseHistorySearch.FormatSearchPrompt("missing", string.Empty, hasMatch: false, isForwardSearch: true); + + Assert.Equal("(failed forward-i-search)`missing`: ", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_HighlightsMatchedSubstring() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("que", "select query foo", hasMatch: true); + + Assert.Equal("(reverse-i-search)`que`: select [underline yellow]que[/]ry foo", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_IsCaseInsensitive() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("que", "SELECT Query foo", hasMatch: true); + + Assert.Equal("(reverse-i-search)`que`: SELECT [underline yellow]Que[/]ry foo", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_HighlightsAllOccurrences() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("a", "ababa", hasMatch: true); + + Assert.Equal("(reverse-i-search)`a`: [underline yellow]a[/]b[underline yellow]a[/]b[underline yellow]a[/]", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_FailedState_DoesNotHighlight() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("missing", string.Empty, hasMatch: false); + + Assert.Equal("(failed reverse-i-search)`missing`: ", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_EscapesMarkupBracketsInMatch() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup("foo", "[foo] bar", hasMatch: true); + + Assert.Equal("(reverse-i-search)`foo`: [[[underline yellow]foo[/]]] bar", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_WithSyntaxHighlighter_PreservesCommandStyling() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup( + "con", + "connect", + hasMatch: true, + isForwardSearch: false, + syntaxHighlighter: ShellInterpreter.Instance); + + Assert.StartsWith("(reverse-i-search)`con`: ", prompt); + Assert.Contains("[underline yellow]con[/]", prompt); + Assert.DoesNotContain("connect[/]", prompt.Replace("[underline yellow]con[/]nect[/]", string.Empty)); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_WithSyntaxHighlighter_RendersValidSpectreMarkup() + { + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup( + "con", + "connect \"https://example.com:443/\"", + hasMatch: true, + isForwardSearch: false, + syntaxHighlighter: ShellInterpreter.Instance); + + // The body after the prefix must render as Spectre.Console markup without throwing. + var body = prompt["(reverse-i-search)`con`: ".Length..]; + var ex = Record.Exception(() => new Markup(body).GetSegments(AnsiConsole.Console).ToList()); + Assert.Null(ex); + Assert.Contains("[underline yellow]con[/]", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_TruncatesToMaxWidthAndKeepsMatchVisible() + { + const int MaxWidth = 45; + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup( + "needle", + "prefix " + new string('x', 50) + " needle suffix", + hasMatch: true, + isForwardSearch: false, + syntaxHighlighter: ShellInterpreter.Instance, + maxWidth: MaxWidth); + + var segments = new Markup(prompt).GetSegments(AnsiConsole.Console).ToList(); + var text = string.Concat(segments.Select(segment => segment.Text)); + + Assert.True(text.Length <= MaxWidth); + Assert.Contains("...", text); + Assert.Contains("needle", text); + Assert.Contains("[underline yellow]needle[/]", prompt); + } + + [Fact] + public void ReverseSearch_FormatSearchPromptMarkup_TruncatesLongQueryToFitMaxWidth() + { + const int MaxWidth = 40; + var longQuery = new string('a', 100); + var prompt = ReverseHistorySearch.FormatSearchPromptMarkup( + longQuery, + "match", + hasMatch: true, + isForwardSearch: false, + syntaxHighlighter: null, + maxWidth: MaxWidth); + + var text = string.Concat(new Markup(prompt).GetSegments(AnsiConsole.Console).Select(s => s.Text)); + + Assert.True(text.Length <= MaxWidth, $"Prompt length {text.Length} exceeded max {MaxWidth}: '{text}'"); + Assert.Contains("...", text); + } + + private static LineEditorContext CreateContext(string content, int position) + { + var buffer = new LineBuffer(content); + buffer.Move(position); + return new LineEditorContext(buffer, null!); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs index 3e112da..de6db4b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.Highlighter.cs @@ -27,6 +27,15 @@ public partial class ShellInterpreter : IHighlighter /// IRenderable IHighlighter.BuildHighlightedText(string text) + { + return new Markup(this.BuildHighlightedMarkup(text)); + } + + /// + /// Builds the syntax-highlighted Spectre.Console markup string for the given shell input. + /// Returns escaped plain text on parse errors. + /// + internal string BuildHighlightedMarkup(string text) { var parser = new StatementParser(text); Statement? statement = null; @@ -49,7 +58,8 @@ IRenderable IHighlighter.BuildHighlightedText(string text) statement.Accept(highlighter); var result = highlighter.GetResult(); this.oldHighlightStatement = statement; - return new Markup(result); + this.oldHighlightedText = text; + return result; } catch { @@ -66,7 +76,7 @@ IRenderable IHighlighter.BuildHighlightedText(string text) this.oldHighlightedText = text; this.oldHighlightStatement.Accept(highlighter); var result = highlighter.GetResult(); - return new Markup(result); + return result; } } catch @@ -77,12 +87,13 @@ IRenderable IHighlighter.BuildHighlightedText(string text) #pragma warning restore CZ0001 // Empty Catch Clause // fall back to non highlighted text in case of any errors. - return new Markup(Markup.Escape(text)); + return Markup.Escape(text); } private void ClearHighlightStatement() { this.oldHighlightStatement = null; + this.oldHighlightedText = null; } internal class HighlightingVisitor : IAstVisitor diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs index 09a9260..7460313 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ShellInterpreter.cs @@ -8,6 +8,7 @@ namespace Azure.Data.Cosmos.Shell.Core; using System.Text.Json; using System.Text.Json.Serialization; using Azure.Data.Cosmos.Shell.Commands; +using Azure.Data.Cosmos.Shell.KeyBindings; using Azure.Data.Cosmos.Shell.Parser; using Azure.Data.Cosmos.Shell.States; using Azure.Data.Cosmos.Shell.Util; @@ -119,6 +120,8 @@ internal static char CSVSeparator internal string HistoryFile { get; private set; } + internal IReadOnlyList History => this.history; + internal string? LastBuffer { get; set; } internal string? OriginalString { get; set; } @@ -1107,6 +1110,18 @@ private LineEditor CreateLineEditor() lineEditor.KeyBindings.Add(ConsoleKey.Escape); lineEditor.KeyBindings.Add(ConsoleKey.L, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.A, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.E, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.U, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.K, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.W, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.P, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.N, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.B, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.F, ConsoleModifiers.Control); + lineEditor.KeyBindings.Add(ConsoleKey.D, ConsoleModifiers.Control, () => new ExitShellCommand(this)); + lineEditor.KeyBindings.Add(ConsoleKey.R, ConsoleModifiers.Control, () => new ReverseSearchHistoryCommand(this)); + lineEditor.KeyBindings.Add(ConsoleKey.S, ConsoleModifiers.Control, () => new ReverseSearchHistoryCommand(this, startsForward: true)); lineEditor.KeyBindings.Add(ConsoleKey.Tab, () => new CosmosCompleteCommand(this, AutoComplete.Next)); lineEditor.KeyBindings.Add(ConsoleKey.Tab, ConsoleModifiers.Control, () => new CosmosCompleteCommand(this, AutoComplete.Previous)); foreach (var line in this.history) diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearCurrentLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearCurrentLineCommand.cs similarity index 90% rename from CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearCurrentLineCommand.cs rename to CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearCurrentLineCommand.cs index bebff62..c36ff9b 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearCurrentLineCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearCurrentLineCommand.cs @@ -2,7 +2,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. //------------------------------------------------------------ -namespace Azure.Data.Cosmos.Shell.Core; +namespace Azure.Data.Cosmos.Shell.KeyBindings; using RadLine; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearScreenCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs similarity index 89% rename from CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearScreenCommand.cs rename to CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs index d7d96c0..fcd4426 100644 --- a/CosmosDBShell/Azure.Data.Cosmos.Shell.Core/ClearScreenCommand.cs +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ClearScreenCommand.cs @@ -2,7 +2,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. //------------------------------------------------------------ -namespace Azure.Data.Cosmos.Shell.Core; +namespace Azure.Data.Cosmos.Shell.KeyBindings; using RadLine; using Spectre.Console; diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeletePreviousWordCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeletePreviousWordCommand.cs new file mode 100644 index 0000000..4442dcf --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeletePreviousWordCommand.cs @@ -0,0 +1,41 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class DeletePreviousWordCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + var end = context.Buffer.Position; + if (end <= 0) + { + return; + } + + var content = context.Buffer.Content; + var start = end; + + while (start > 0 && char.IsWhiteSpace(content[start - 1])) + { + start--; + } + + while (start > 0 && !char.IsWhiteSpace(content[start - 1])) + { + start--; + } + + var length = end - start; + if (length <= 0) + { + return; + } + + context.Buffer.Clear(start, length); + context.Buffer.Move(start); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToEndOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToEndOfLineCommand.cs new file mode 100644 index 0000000..ccff2ee --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToEndOfLineCommand.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class DeleteToEndOfLineCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + var position = context.Buffer.Position; + var length = context.Buffer.Length - position; + if (length <= 0) + { + return; + } + + context.Buffer.Clear(position, length); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToStartOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToStartOfLineCommand.cs new file mode 100644 index 0000000..273c1e3 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/DeleteToStartOfLineCommand.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class DeleteToStartOfLineCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + var position = context.Buffer.Position; + if (position <= 0) + { + return; + } + + context.Buffer.Clear(0, position); + context.Buffer.Move(0); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ExitShellCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ExitShellCommand.cs new file mode 100644 index 0000000..17a4466 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ExitShellCommand.cs @@ -0,0 +1,29 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using Azure.Data.Cosmos.Shell.Core; +using RadLine; + +internal class ExitShellCommand(ShellInterpreter shell) : LineEditorCommand +{ + private readonly ShellInterpreter shell = shell; + + public override void Execute(LineEditorContext context) + { + if (context.Buffer.Length == 0) + { + this.shell.IsRunning = false; + context.Submit(SubmitAction.Cancel); + return; + } + + var position = context.Buffer.Position; + if (position < context.Buffer.Length) + { + context.Buffer.Clear(position, 1); + } + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorLeftCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorLeftCommand.cs new file mode 100644 index 0000000..5a28971 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorLeftCommand.cs @@ -0,0 +1,15 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class MoveCursorLeftCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveLeft(1); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorRightCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorRightCommand.cs new file mode 100644 index 0000000..4692080 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveCursorRightCommand.cs @@ -0,0 +1,15 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class MoveCursorRightCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveRight(1); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToEndOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToEndOfLineCommand.cs new file mode 100644 index 0000000..b7474f2 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToEndOfLineCommand.cs @@ -0,0 +1,15 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class MoveToEndOfLineCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveEnd(); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToStartOfLineCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToStartOfLineCommand.cs new file mode 100644 index 0000000..5616e20 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/MoveToStartOfLineCommand.cs @@ -0,0 +1,15 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using RadLine; + +internal class MoveToStartOfLineCommand : LineEditorCommand +{ + public override void Execute(LineEditorContext context) + { + context.Buffer.MoveHome(); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearch.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearch.cs new file mode 100644 index 0000000..7da2258 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearch.cs @@ -0,0 +1,390 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using System; +using System.Collections.Generic; +using System.Text; +using Azure.Data.Cosmos.Shell.Core; +using Azure.Data.Cosmos.Shell.Util; +using Spectre.Console; + +internal static class ReverseHistorySearch +{ + public static int FindReverseMatch(IReadOnlyList history, string query, int skipMatches) + { + if (string.IsNullOrEmpty(query)) + { + return -1; + } + + var matchesSeen = 0; + for (var i = history.Count - 1; i >= 0; i--) + { + if (history[i].Contains(query, StringComparison.OrdinalIgnoreCase)) + { + if (matchesSeen == skipMatches) + { + return i; + } + + matchesSeen++; + } + } + + return -1; + } + + public static ReverseHistorySearchResult FindInitialMatch(IReadOnlyList history, string query) + { + return CreateResult(history, query, skip: 0); + } + + public static ReverseHistorySearchResult FindInitialForwardMatch(IReadOnlyList history, string query) + { + var matchCount = CountMatches(history, query); + return matchCount > 0 ? CreateResult(history, query, skip: matchCount - 1) : ReverseHistorySearchResult.None(); + } + + public static ReverseHistorySearchResult FindNextMatch(IReadOnlyList history, string query, int currentSkip) + { + var nextSkip = currentSkip + 1; + var next = CreateResult(history, query, nextSkip); + if (next.HasMatch) + { + return next; + } + + return CreateResult(history, query, skip: 0); + } + + public static ReverseHistorySearchResult FindPreviousMatch(IReadOnlyList history, string query, int currentSkip) + { + var matchCount = CountMatches(history, query); + if (matchCount <= 0) + { + return ReverseHistorySearchResult.None(); + } + + var previousSkip = currentSkip <= 0 ? matchCount - 1 : currentSkip - 1; + return CreateResult(history, query, previousSkip); + } + + public static string FormatSearchPrompt(string query, string match, bool hasMatch, bool isForwardSearch = false) + { + var prefix = GetSearchPrefix(query, hasMatch, isForwardSearch); + return $"({prefix})`{query}`: {match}"; + } + + public static string FormatSearchPromptMarkup(string query, string match, bool hasMatch, bool isForwardSearch = false) + { + return FormatSearchPromptMarkup(query, match, hasMatch, isForwardSearch, syntaxHighlighter: null); + } + + public static string FormatSearchPromptMarkup(string query, string match, bool hasMatch, bool isForwardSearch, ShellInterpreter? syntaxHighlighter) + { + return FormatSearchPromptMarkup(query, match, hasMatch, isForwardSearch, syntaxHighlighter, maxWidth: null); + } + + public static string FormatSearchPromptMarkup(string query, string match, bool hasMatch, bool isForwardSearch, ShellInterpreter? syntaxHighlighter, int? maxWidth) + { + var prefix = GetSearchPrefix(query, hasMatch, isForwardSearch); + var displayedQuery = TruncateQueryForPrompt(query, prefix, maxWidth); + var plainPrefix = $"({prefix})`{displayedQuery}`: "; + int? maxMatchLength = maxWidth.HasValue ? Math.Max(0, maxWidth.Value - plainPrefix.Length) : null; + var renderedMatch = HighlightMatch(TruncateMatch(match, query, hasMatch, maxMatchLength), query, hasMatch, syntaxHighlighter); + return $"({prefix})`{Markup.Escape(displayedQuery)}`: {renderedMatch}"; + } + + /// + /// Ensures the rendered prompt prefix `({prefix})`{query}`: ` fits inside + /// . If the user's query is too long, the + /// returned string is shortened with an ellipsis so the whole prompt + /// stays on a single console row ('s + /// `ClearLine` only clears one row). + /// + private static string TruncateQueryForPrompt(string query, string prefix, int? maxWidth) + { + if (!maxWidth.HasValue) + { + return query; + } + + // Layout: "(" + prefix + ")`" + query + "`: " minimum match budget. + const string Ellipsis = "..."; + const int MinMatchBudget = 1; + var fixedOverhead = 1 + prefix.Length + 2 + 3; // "(", prefix, ")`", "`: " + var queryBudget = maxWidth.Value - fixedOverhead - MinMatchBudget; + if (queryBudget < 0) + { + queryBudget = 0; + } + + if (query.Length <= queryBudget) + { + return query; + } + + if (queryBudget <= Ellipsis.Length) + { + return query[..queryBudget]; + } + + return string.Concat(query.AsSpan(0, queryBudget - Ellipsis.Length), Ellipsis); + } + + private static string GetSearchPrefix(string query, bool hasMatch, bool isForwardSearch) + { + if (!string.IsNullOrEmpty(query) && !hasMatch) + { + return MessageService.GetString(isForwardSearch ? "history-search-failed-forward" : "history-search-failed-reverse"); + } + + return MessageService.GetString(isForwardSearch ? "history-search-forward" : "history-search-reverse"); + } + + private static string TruncateMatch(string match, string query, bool hasMatch, int? maxLength) + { + const string Marker = "..."; + + if (!maxLength.HasValue || match.Length <= maxLength.Value) + { + return match; + } + + if (maxLength.Value <= 0) + { + return string.Empty; + } + + if (maxLength.Value <= Marker.Length) + { + return match[..maxLength.Value]; + } + + var contentLength = maxLength.Value - Marker.Length; + if (!hasMatch || string.IsNullOrEmpty(query)) + { + return match[..contentLength] + Marker; + } + + var hit = match.IndexOf(query, StringComparison.OrdinalIgnoreCase); + if (hit < 0 || hit <= contentLength) + { + return match[..contentLength] + Marker; + } + + var start = Math.Min(hit, match.Length - contentLength); + return Marker + match.Substring(start, contentLength); + } + + private static string HighlightMatch(string match, string query, bool hasMatch, ShellInterpreter? syntaxHighlighter) + { + if (string.IsNullOrEmpty(match)) + { + return string.Empty; + } + + var styledMarkup = syntaxHighlighter != null + ? SafeBuildHighlightedMarkup(syntaxHighlighter, match) + : Markup.Escape(match); + + if (!hasMatch || string.IsNullOrEmpty(query)) + { + return styledMarkup; + } + + var ranges = FindAllMatches(match, query); + if (ranges.Count == 0) + { + return styledMarkup; + } + + return OverlayUnderline(styledMarkup, ranges); + } + + private static string SafeBuildHighlightedMarkup(ShellInterpreter highlighter, string text) + { + try + { + return highlighter.BuildHighlightedMarkup(text); + } + catch (Exception) + { + return Markup.Escape(text); + } + } + + private static List<(int Start, int End)> FindAllMatches(string match, string query) + { + var ranges = new List<(int, int)>(); + var index = 0; + while (index < match.Length) + { + var hit = match.IndexOf(query, index, StringComparison.OrdinalIgnoreCase); + if (hit < 0) + { + break; + } + + ranges.Add((hit, hit + query.Length)); + index = hit + query.Length; + } + + return ranges; + } + + /// + /// Walks a Spectre.Console markup string and wraps the plain-text positions in + /// with [underline yellow]...[/]. Underline is closed before any markup tag boundary and reopened on the + /// other side so that the output remains well-nested. + /// + private static string OverlayUnderline(string markup, IReadOnlyList<(int Start, int End)> ranges) + { + const string OpenUnderline = "[underline yellow]"; + const string CloseUnderline = "[/]"; + + var sb = new StringBuilder(markup.Length + (ranges.Count * (OpenUnderline.Length + CloseUnderline.Length))); + var plainIndex = 0; + var inUnderline = false; + var i = 0; + + bool ShouldUnderline(int p) + { + foreach (var r in ranges) + { + if (p >= r.Start && p < r.End) + { + return true; + } + } + + return false; + } + + while (i < markup.Length) + { + var c = markup[i]; + + if (c == '[' && i + 1 < markup.Length && markup[i + 1] == '[') + { + // Escaped literal '[' + if (!inUnderline && ShouldUnderline(plainIndex)) + { + sb.Append(OpenUnderline); + inUnderline = true; + } + + sb.Append("[["); + i += 2; + plainIndex++; + if (inUnderline && !ShouldUnderline(plainIndex)) + { + sb.Append(CloseUnderline); + inUnderline = false; + } + + continue; + } + + if (c == ']' && i + 1 < markup.Length && markup[i + 1] == ']') + { + // Escaped literal ']' + if (!inUnderline && ShouldUnderline(plainIndex)) + { + sb.Append(OpenUnderline); + inUnderline = true; + } + + sb.Append("]]"); + i += 2; + plainIndex++; + if (inUnderline && !ShouldUnderline(plainIndex)) + { + sb.Append(CloseUnderline); + inUnderline = false; + } + + continue; + } + + if (c == '[') + { + // Markup tag (open or close). Close underline before the tag, copy verbatim, reopen after if still in match. + if (inUnderline) + { + sb.Append(CloseUnderline); + inUnderline = false; + } + + var tagEnd = markup.IndexOf(']', i + 1); + if (tagEnd < 0) + { + // Malformed; copy rest verbatim. + sb.Append(markup, i, markup.Length - i); + return sb.ToString(); + } + + sb.Append(markup, i, tagEnd - i + 1); + i = tagEnd + 1; + if (ShouldUnderline(plainIndex)) + { + sb.Append(OpenUnderline); + inUnderline = true; + } + + continue; + } + + // Plain character + if (!inUnderline && ShouldUnderline(plainIndex)) + { + sb.Append(OpenUnderline); + inUnderline = true; + } + + sb.Append(c); + i++; + plainIndex++; + if (inUnderline && !ShouldUnderline(plainIndex)) + { + sb.Append(CloseUnderline); + inUnderline = false; + } + } + + if (inUnderline) + { + sb.Append(CloseUnderline); + } + + return sb.ToString(); + } + + private static int CountMatches(IReadOnlyList history, string query) + { + if (string.IsNullOrEmpty(query)) + { + return 0; + } + + var count = 0; + foreach (var item in history) + { + if (item.Contains(query, StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + + return count; + } + + private static ReverseHistorySearchResult CreateResult(IReadOnlyList history, string query, int skip) + { + var index = FindReverseMatch(history, query, skip); + return index >= 0 ? new ReverseHistorySearchResult(index, skip, history[index]) : ReverseHistorySearchResult.None(skip); + } +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearchResult.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearchResult.cs new file mode 100644 index 0000000..93f50ea --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseHistorySearchResult.cs @@ -0,0 +1,12 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +internal readonly record struct ReverseHistorySearchResult(int Index, int Skip, string Match) +{ + public bool HasMatch => this.Index >= 0; + + public static ReverseHistorySearchResult None(int skip = 0) => new(-1, skip, string.Empty); +} diff --git a/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs new file mode 100644 index 0000000..18ea7e2 --- /dev/null +++ b/CosmosDBShell/Azure.Data.Cosmos.Shell.KeyBindings/ReverseSearchHistoryCommand.cs @@ -0,0 +1,210 @@ +//------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +//------------------------------------------------------------ + +namespace Azure.Data.Cosmos.Shell.KeyBindings; + +using System; +using System.Collections.Generic; +using System.IO; +using Azure.Data.Cosmos.Shell.Core; +using RadLine; +using Spectre.Console; + +internal class ReverseSearchHistoryCommand(ShellInterpreter shell, bool startsForward = false) : LineEditorCommand +{ + private readonly ShellInterpreter shell = shell; + private readonly bool startsForward = startsForward; + + public override void Execute(LineEditorContext context) + { + var originalContent = context.Buffer.Content; + var originalPosition = context.Buffer.Position; + var query = string.Empty; + var result = ReverseHistorySearchResult.None(); + var accepted = false; + var isForwardSearch = this.startsForward; + var promptRow = TryGetCursorTop(); + var restoreControlCHandling = TrySetTreatControlCAsInput(true, out var originalTreatControlCAsInput); + + try + { + Render(query, result, isForwardSearch, promptRow, this.shell); + + while (true) + { + var key = Console.ReadKey(intercept: true); + + if (key.Key == ConsoleKey.Enter) + { + accepted = result.HasMatch; + break; + } + + if (key.Key == ConsoleKey.Escape || + (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.G) || + (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.C)) + { + break; + } + + if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.R) + { + isForwardSearch = false; + if (result.HasMatch) + { + result = ReverseHistorySearch.FindNextMatch(this.shell.History, query, result.Skip); + } + + Render(query, result, isForwardSearch, promptRow, this.shell); + continue; + } + + if (key.Modifiers.HasFlag(ConsoleModifiers.Control) && key.Key == ConsoleKey.S) + { + isForwardSearch = true; + if (result.HasMatch) + { + result = ReverseHistorySearch.FindPreviousMatch(this.shell.History, query, result.Skip); + } + + Render(query, result, isForwardSearch, promptRow, this.shell); + continue; + } + + if (key.Key == ConsoleKey.Backspace) + { + if (query.Length > 0) + { + query = query[..^1]; + result = FindInitialMatch(this.shell.History, query, isForwardSearch); + } + + Render(query, result, isForwardSearch, promptRow, this.shell); + continue; + } + + if (!char.IsControl(key.KeyChar)) + { + query += key.KeyChar; + result = FindInitialMatch(this.shell.History, query, isForwardSearch); + Render(query, result, isForwardSearch, promptRow, this.shell); + continue; + } + } + } + finally + { + if (restoreControlCHandling) + { + TrySetTreatControlCAsInput(originalTreatControlCAsInput, out _); + } + + ClearLine(promptRow); + } + + var newContent = accepted ? result.Match : originalContent; + context.Buffer.Clear(0, context.Buffer.Length); + context.Buffer.Move(0); + if (newContent.Length > 0) + { + context.Buffer.Insert(newContent); + } + + context.Buffer.Move(accepted ? newContent.Length : Math.Min(originalPosition, newContent.Length)); + } + + private static ReverseHistorySearchResult FindInitialMatch(IReadOnlyList history, string query, bool isForwardSearch) + { + return isForwardSearch ? ReverseHistorySearch.FindInitialForwardMatch(history, query) : ReverseHistorySearch.FindInitialMatch(history, query); + } + + private static void Render(string query, ReverseHistorySearchResult result, bool isForwardSearch, int? promptRow, ShellInterpreter shell) + { + ClearLine(promptRow); + AnsiConsole.Markup(ReverseHistorySearch.FormatSearchPromptMarkup(query, result.Match, result.HasMatch, isForwardSearch, shell, TryGetLineWidth())); + } + + private static void ClearLine(int? promptRow) + { + try + { + var width = TryGetLineWidth(); + if (!width.HasValue || width.Value <= 0) + { + Console.Write('\r'); + return; + } + + var row = promptRow ?? Console.CursorTop; + Console.SetCursorPosition(0, row); + Console.Write(new string(' ', width.Value)); + Console.SetCursorPosition(0, row); + } + catch (IOException) + { + Console.Write('\r'); + } + catch (ArgumentOutOfRangeException) + { + Console.Write('\r'); + } + catch (InvalidOperationException) + { + Console.Write('\r'); + } + } + + private static int? TryGetCursorTop() + { + try + { + return Console.CursorTop; + } + catch (IOException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static int? TryGetLineWidth() + { + try + { + var width = Console.WindowWidth > 0 ? Console.WindowWidth : Console.BufferWidth; + return width > 1 ? width - 1 : null; + } + catch (IOException) + { + return null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private static bool TrySetTreatControlCAsInput(bool value, out bool originalValue) + { + try + { + originalValue = Console.TreatControlCAsInput; + Console.TreatControlCAsInput = value; + return true; + } + catch (IOException) + { + originalValue = false; + return false; + } + catch (InvalidOperationException) + { + originalValue = false; + return false; + } + } +} diff --git a/CosmosDBShell/lang/en.ftl b/CosmosDBShell/lang/en.ftl index 2fd2073..380d106 100644 --- a/CosmosDBShell/lang/en.ftl +++ b/CosmosDBShell/lang/en.ftl @@ -10,6 +10,10 @@ shell-connect-static-token-expiry = Expires in { $timespan } (expiration: { $exp shell-connect-vscode-credential-auth = Connecting with Visual Studio Code credential... shell-connect-vscode-credential-fallback = Visual Studio Code credential unavailable, falling back... shell-connect-devicecode-fallback = Browser authentication failed, falling back to device code authentication... +history-search-reverse = reverse-i-search +history-search-forward = forward-i-search +history-search-failed-reverse = failed reverse-i-search +history-search-failed-forward = failed forward-i-search yes_char = Y no_char = N diff --git a/docs/navigation.md b/docs/navigation.md index 1382487..044182f 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -179,6 +179,27 @@ These commands accept and process piped JSON: | `jq` | Filters/transforms piped JSON | | `ftab` | Formats piped JSON as table | +## Keyboard Shortcuts + +Available at the interactive prompt: + +| Shortcut | Action | +| -------- | ------ | +| `Up` / `Down` | Previous / next history entry | +| `Ctrl+P` / `Ctrl+N` | Previous / next history entry | +| `Ctrl+B` / `Ctrl+F` | Move cursor left / right | +| `Tab` / `Ctrl+Tab` | Next / previous completion | +| `Esc` | Clear current line | +| `Ctrl+L` | Clear screen | +| `Ctrl+A` | Move cursor to start of line | +| `Ctrl+E` | Move cursor to end of line | +| `Ctrl+U` | Delete text before cursor | +| `Ctrl+K` | Delete text after cursor | +| `Ctrl+W` | Delete previous word | +| `Ctrl+D` | Exit shell when prompt is empty; otherwise delete character under cursor | +| `Ctrl+R` | Reverse search history (type to filter, repeat for older matches, Enter accepts, Esc/Ctrl+G/Ctrl+C cancels) | +| `Ctrl+S` | Forward search history (type to filter, repeat for newer matches, Enter accepts, Esc/Ctrl+G/Ctrl+C cancels) | + ## CLI Arguments Start the shell with options to customize behavior: