diff --git a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.Interactive_api_is_not_changed.approved.txt b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.Interactive_api_is_not_changed.approved.txt index bb73028da4..e57e3f0ca1 100644 --- a/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.Interactive_api_is_not_changed.approved.txt +++ b/src/Microsoft.DotNet.Interactive.ApiCompatibility.Tests/ApiCompatibilityTests.Interactive_api_is_not_changed.approved.txt @@ -366,6 +366,13 @@ Microsoft.DotNet.Interactive.Commands public class ImportDocument : KernelCommand, System.IEquatable .ctor(System.String filePath, System.String targetKernelName = null) public System.String FilePath { get;} + public class InputDescription + .ctor(System.String name, System.String prompt = null) + public System.String Name { get;} + public System.String Prompt { get;} + public System.String SaveAs { get; set;} + public System.String TypeHint { get; set;} + public System.String GetPropertyNameForJsonSerialization() public abstract class KernelCommand, System.IEquatable public System.Uri DestinationUri { get; set;} public Microsoft.DotNet.Interactive.KernelCommandInvocation Handler { get; set;} @@ -396,11 +403,15 @@ Microsoft.DotNet.Interactive.Commands public class RequestHoverText : LanguageServiceCommand, System.IEquatable .ctor(System.String code, Microsoft.DotNet.Interactive.LinePosition linePosition, System.String targetKernelName = null) public class RequestInput : KernelCommand, System.IEquatable - .ctor(System.String prompt, System.String targetKernelName = null, System.String inputTypeHint = null) + .ctor(System.String prompt, System.String inputTypeHint = null) public System.String InputTypeHint { get; set;} public System.Boolean IsPassword { get;} + public System.String ParameterName { get; set;} public System.String Prompt { get;} public System.String SaveAs { get; set;} + public class RequestInputs : KernelCommand, System.IEquatable + .ctor() + public System.Collections.Generic.List Inputs { get; set;} public class RequestKernelInfo : KernelCommand, System.IEquatable .ctor(System.String targetKernelName = null) .ctor(System.Uri destinationUri) @@ -464,6 +475,7 @@ Microsoft.DotNet.Interactive.Connection public static KernelCommandAndEventReceiver FromNamedPipe(System.IO.Pipes.PipeStream stream) public static KernelCommandAndEventReceiver FromObservable(System.IObservable messages) public static KernelCommandAndEventReceiver FromTextReader(System.IO.TextReader reader) + .ctor(ReadCommandOrEventAsync readCommandOrEvent) .ctor(ReadCommandOrEvent readCommandOrEvent) public System.Void Dispose() public System.IDisposable Subscribe(System.IObserver observer) @@ -518,6 +530,11 @@ Microsoft.DotNet.Interactive.Connection public System.IAsyncResult BeginInvoke(System.Threading.CancellationToken cancellationToken, System.AsyncCallback callback, System.Object object) public CommandOrEvent EndInvoke(System.IAsyncResult result) public CommandOrEvent Invoke(System.Threading.CancellationToken cancellationToken = null) + public delegate ReadCommandOrEventAsync : System.MulticastDelegate, System.ICloneable, System.Runtime.Serialization.ISerializable + .ctor(System.Object object, System.IntPtr method) + public System.IAsyncResult BeginInvoke(System.Threading.CancellationToken cancellationToken, System.AsyncCallback callback, System.Object object) + public System.Threading.Tasks.Task EndInvoke(System.IAsyncResult result) + public System.Threading.Tasks.Task Invoke(System.Threading.CancellationToken cancellationToken = null) public static class Serializer public static System.Text.Json.JsonSerializerOptions JsonSerializerOptions { get;} public static CommandOrEvent DeserializeCommandOrEvent(System.String json) @@ -622,6 +639,9 @@ Microsoft.DotNet.Interactive.Events public class InputProduced : KernelEvent .ctor(System.String value, Microsoft.DotNet.Interactive.Commands.RequestInput command) public System.String Value { get;} + public class InputsProduced : KernelEvent + .ctor(System.Collections.Generic.Dictionary values, Microsoft.DotNet.Interactive.Commands.RequestInputs command) + public System.Collections.Generic.Dictionary Values { get;} public enum InsertTextFormat : System.Enum, System.IComparable, System.IConvertible, System.IFormattable, System.ISpanFormattable PlainText=1 Snippet=2 diff --git a/src/Microsoft.DotNet.Interactive.ExtensionLab.Tests/Microsoft.DotNet.Interactive.ExtensionLab.Tests.v3.ncrunchproject b/src/Microsoft.DotNet.Interactive.ExtensionLab.Tests/Microsoft.DotNet.Interactive.ExtensionLab.Tests.v3.ncrunchproject index 1db9ce062a..45c67e4fb9 100644 --- a/src/Microsoft.DotNet.Interactive.ExtensionLab.Tests/Microsoft.DotNet.Interactive.ExtensionLab.Tests.v3.ncrunchproject +++ b/src/Microsoft.DotNet.Interactive.ExtensionLab.Tests/Microsoft.DotNet.Interactive.ExtensionLab.Tests.v3.ncrunchproject @@ -14,6 +14,9 @@ Microsoft.DotNet.Interactive.ExtensionLab.Tests.DataFrameTypeGeneratorTests + + Microsoft.DotNet.Interactive.ExtensionLab.Tests.InspectTests + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.PowerShell.Tests/Microsoft.DotNet.Interactive.PowerShell.Tests.v3.ncrunchproject b/src/Microsoft.DotNet.Interactive.PowerShell.Tests/Microsoft.DotNet.Interactive.PowerShell.Tests.v3.ncrunchproject index 8c9653c564..d220d12117 100644 --- a/src/Microsoft.DotNet.Interactive.PowerShell.Tests/Microsoft.DotNet.Interactive.PowerShell.Tests.v3.ncrunchproject +++ b/src/Microsoft.DotNet.Interactive.PowerShell.Tests/Microsoft.DotNet.Interactive.PowerShell.Tests.v3.ncrunchproject @@ -5,6 +5,24 @@ Microsoft.DotNet.Interactive.PowerShell.Tests.SecretManagerTests + + Microsoft.DotNet.Interactive.PowerShell.Tests.PowerShellKernelTests.GetCorrectProfilePaths + + + Microsoft.DotNet.Interactive.PowerShell.Tests.PowerShellKernelTests.PowerShell_get_history_should_work + + + Microsoft.DotNet.Interactive.PowerShell.Tests.PowerShellKernelTests.PowerShell_progress_sends_updated_display_values + + + Microsoft.DotNet.Interactive.PowerShell.Tests.PowerShellKernelTests.PowerShell_token_variables_work + + + Microsoft.DotNet.Interactive.PowerShell.Tests.PowerShellKernelTests.TryGetVariable_unwraps_PowerShell_object("$x = New-Object -TypeName System.IO.FileInfo -ArgumentList c:\\temp\\some.txt", "System.IO.FileInfo") + + + Microsoft.DotNet.Interactive.PowerShell.Tests.PowerShellKernelTests.When_code_produces_errors_then_the_command_fails + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.PowerShell/SecretManager.cs b/src/Microsoft.DotNet.Interactive.PowerShell/SecretManager.cs index bcca22d592..91061f28ab 100644 --- a/src/Microsoft.DotNet.Interactive.PowerShell/SecretManager.cs +++ b/src/Microsoft.DotNet.Interactive.PowerShell/SecretManager.cs @@ -6,7 +6,6 @@ using System; namespace Microsoft.DotNet.Interactive.PowerShell; -using System.Diagnostics.CodeAnalysis; public class SecretManager { diff --git a/src/Microsoft.DotNet.Interactive.Tests/Connection/ObservableCommandAndEventReceiverTests.cs b/src/Microsoft.DotNet.Interactive.Tests/Connection/ObservableCommandAndEventReceiverTests.cs index f140a03bdd..19618cf978 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Connection/ObservableCommandAndEventReceiverTests.cs +++ b/src/Microsoft.DotNet.Interactive.Tests/Connection/ObservableCommandAndEventReceiverTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; @@ -111,13 +111,13 @@ public async Task When_receiver_is_disposed_then_no_further_reads_occur() for (int i = 0; i < 2; i++) { - _messageQueue.Add(new CommandOrEvent(new SubmitCode(""))); + _messageQueue.Add(new CommandOrEvent(new SubmitCode(i.ToString()))); } - using var receiver = new KernelCommandAndEventReceiver(t => + var receiver = new KernelCommandAndEventReceiver(t => { - var commandOrEvent = _messageQueue.Take(t); readCount++; + var commandOrEvent = _messageQueue.Take(t); return commandOrEvent; }); diff --git a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInput.json b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInput.json index 47c38eb341..406d2d476a 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInput.json +++ b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInput.json @@ -2,11 +2,12 @@ "token": "the-token", "commandType": "RequestInput", "command": { + "parameterName": null, "prompt": "provide answer", "isPassword": true, - "type": "password", "saveAs": null, - "targetKernelName": "vscode", + "type": "password", + "targetKernelName": null, "originUri": null, "destinationUri": null }, diff --git a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInputs.json b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInputs.json new file mode 100644 index 0000000000..ed38196634 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Command_contract_has_not_been_broken.approved.RequestInputs.json @@ -0,0 +1,21 @@ +{ + "token": "the-token", + "commandType": "RequestInputs", + "command": { + "inputs": [ + { + "name": "value", + "prompt": "Please enter a value.", + "saveAs": "the-password", + "type": "password" + } + ], + "targetKernelName": null, + "originUri": null, + "destinationUri": null + }, + "routingSlip": [ + "kernel://somelocation/kernelName?tag=arrived", + "kernel://somelocation/kernelName" + ] +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputProduced.json b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputProduced.json index 6305f8077d..461b9e7bee 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputProduced.json +++ b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputProduced.json @@ -7,11 +7,12 @@ "token": "the-token", "commandType": "RequestInput", "command": { + "parameterName": "filePath", "prompt": "What is the path to the log file?", "isPassword": false, + "saveAs": "the-file", "type": "file", - "saveAs": null, - "targetKernelName": "vscode", + "targetKernelName": null, "originUri": null, "destinationUri": null }, diff --git a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputsProduced.json b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputsProduced.json new file mode 100644 index 0000000000..20c6079763 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.Event_contract_has_not_been_broken.approved.InputsProduced.json @@ -0,0 +1,32 @@ +{ + "event": { + "values": { + "value": "tops3kr1tstuff" + } + }, + "eventType": "InputsProduced", + "command": { + "token": "the-token", + "commandType": "RequestInputs", + "command": { + "inputs": [ + { + "name": "value", + "prompt": "Please enter a value.", + "saveAs": "the-password", + "type": "password" + } + ], + "targetKernelName": null, + "originUri": null, + "destinationUri": null + }, + "routingSlip": [ + "kernel://somelocation/kernelName?tag=arrived", + "kernel://somelocation/kernelName" + ] + }, + "routingSlip": [ + "kernel://somelocation/kernelName" + ] +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.cs b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.cs index d64efdb73b..ffece65ce4 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.cs +++ b/src/Microsoft.DotNet.Interactive.Tests/Connection/SerializationTests.cs @@ -201,6 +201,20 @@ IEnumerable commands() yield return new RequestHoverText("document-contents", new LinePosition(1, 2)); + yield return new RequestInput(prompt: "provide answer", inputTypeHint: "password"); + + yield return new RequestInputs + { + Inputs = + [ + new InputDescription("value", "Please enter a value.") + { + TypeHint = "password", + SaveAs = "the-password" + } + ] + }; + yield return new RequestSignatureHelp("sig-help-contents", new LinePosition(1, 2)); yield return new SendEditableCode("someKernelName", "code"); @@ -230,8 +244,6 @@ IEnumerable commands() yield return new RequestValue("a", mimeType: HtmlFormatter.MimeType, targetKernelName: "csharp"); - yield return new RequestInput(prompt: "provide answer", inputTypeHint: "password", targetKernelName: "vscode"); - yield return new SendValue( "name", "formatted value", @@ -364,8 +376,7 @@ [new FormattedValue(PlainTextFormatter.MimeType, diagnostic.ToString())], OriginUri = new("kernel://pid-1234/csharp") }); - yield return new KernelReady(new[] - { + yield return new KernelReady([ new KernelInfo("javascript", aliases: new[] { "js" }) { LanguageName = "JavaScript", @@ -389,7 +400,7 @@ [new FormattedValue(PlainTextFormatter.MimeType, diagnostic.ToString())], new KernelCommandInfo(nameof(SubmitCode)) } } - }); + ]); yield return new PackageAdded( new ResolvedPackageReference( @@ -454,7 +465,25 @@ [new FormattedValue(PlainTextFormatter.MimeType, diagnostic.ToString())], "raw value"), new RequestValue("a", mimeType: HtmlFormatter.MimeType, targetKernelName: "csharp")); - yield return new InputProduced("user input", new RequestInput(prompt: "What is the path to the log file?", inputTypeHint: "file", targetKernelName: "vscode")); + yield return new InputProduced("user input", new RequestInput(prompt: "What is the path to the log file?", inputTypeHint: "file") + { + ParameterName = "filePath", + SaveAs = "the-file" + }); + + yield return new InputsProduced( + new Dictionary { ["value"] = "tops3kr1tstuff" }, + new RequestInputs + { + Inputs = + [ + new InputDescription("value", "Please enter a value.") + { + TypeHint = "password", + SaveAs = "the-password" + } + ] + }); } } diff --git a/src/Microsoft.DotNet.Interactive.Tests/Microsoft.DotNet.Interactive.Tests.v3.ncrunchproject b/src/Microsoft.DotNet.Interactive.Tests/Microsoft.DotNet.Interactive.Tests.v3.ncrunchproject index 03c69d7268..65faa43fe4 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Microsoft.DotNet.Interactive.Tests.v3.ncrunchproject +++ b/src/Microsoft.DotNet.Interactive.Tests/Microsoft.DotNet.Interactive.Tests.v3.ncrunchproject @@ -64,9 +64,21 @@ var x = 123; // with some intervening code Microsoft.DotNet.Interactive.Tests.Connection.SerializationTests.Event_contract_has_not_been_broken - - Microsoft.DotNet.Interactive.Tests.RequestInputTests - + + Microsoft.DotNet.Interactive.Tests.RequestInputTests.When_a_saved_value_is_used_then_the_user_is_notified + + + Microsoft.DotNet.Interactive.Tests.RequestInputTests.When_a_value_is_saved_then_the_user_is_notified + + + Microsoft.DotNet.Interactive.Tests.RequestInputTests.When_Save_is_specified_then_subsequent_requests_reuse_the_saved_value + + + Microsoft.DotNet.Interactive.Tests.MultipleInputsWithinMagicCommandsTests.Previously_stored_values_are_used_to_prepopulate_input_fields + + + Microsoft.DotNet.Interactive.Tests.MultipleInputsWithinMagicCommandsTests.Input_field_values_can_be_stored_using_SecretManager + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Tests/MultipleInputsWithinMagicCommandsTests.cs b/src/Microsoft.DotNet.Interactive.Tests/MultipleInputsWithinMagicCommandsTests.cs new file mode 100644 index 0000000000..41d656e2e0 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive.Tests/MultipleInputsWithinMagicCommandsTests.cs @@ -0,0 +1,323 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Microsoft.DotNet.Interactive.App; +using Microsoft.DotNet.Interactive.Commands; +using Microsoft.DotNet.Interactive.CSharp; +using Microsoft.DotNet.Interactive.Directives; +using Microsoft.DotNet.Interactive.Events; +using Microsoft.DotNet.Interactive.PowerShell; +using Microsoft.DotNet.Interactive.Tests.Utility; +using Xunit; + +namespace Microsoft.DotNet.Interactive.Tests; + +public class MultipleInputsWithinMagicCommandsTests : IDisposable +{ + private readonly CompositeKernel _kernel; + private readonly KernelActionDirective _shimCommand; + private readonly List _receivedShimCommands = new(); + private readonly SecretManager _secretManager; + + public MultipleInputsWithinMagicCommandsTests() + { + var powerShellKernel = new PowerShellKernel(); + + _secretManager = new SecretManager(powerShellKernel); + + _kernel = new CompositeKernel + { + new CSharpKernel() + .UseNugetDirective() + .UseKernelHelpers() + .UseValueSharing(), + powerShellKernel, + new KeyValueStoreKernel() + }.UseFormsForMultipleInputs(_secretManager); + + _shimCommand = new("#!shim") + { + KernelCommandType = typeof(ShimCommand), + Parameters = + { + new("--name"), + new("--value") + { + AllowImplicitName = true + }, + new("--another-value") + } + }; + + _kernel.FindKernelByName("csharp") + .AddDirective(_shimCommand, (command, _) => + { + _receivedShimCommands.Add(command); + return Task.CompletedTask; + }); + } + + public class ShimCommand : KernelCommand + { + public string Name { get; set; } + + public string Value { get; set; } + + public string AnotherValue { get; set; } + } + + public void Dispose() + { + _kernel.Dispose(); + } + + [Fact] + public async Task Multiple_inputs_are_bound_within_a_single_magic_command_that_uses_JSON_binding() + { + _kernel.RespondToRequestInputsFormWith(new Dictionary + { + ["name"] = "age", + ["value"] = "123", + ["anotherValue"] = "456" + }); + + var result = await _kernel.SendAsync( + new SubmitCode(""" + #!shim --name @input --value @input:{"type": "date"} --another-value 456 + """, "csharp")); + + result.Events.Should().NotContainErrors(); + + using var _ = new AssertionScope(); + + var receivedCommand= _receivedShimCommands.Should().ContainSingle().Which; + receivedCommand.Name.Should().Be("age"); + receivedCommand.Value.Should().Be("123"); + receivedCommand.AnotherValue.Should().Be("456"); + } + + [Fact] + public async Task Multiple_inputs_are_bound_within_a_single_magic_command_that_uses_custom_binding() + { + _kernel.RespondToRequestInputsFormWith(new Dictionary + { + ["name"] = "age", + ["value"] = "123" + }); + + var result = await _kernel.SendAsync( + new SubmitCode(""" + #!set --name @input:age --value @input:{"type": "number"} + """, "csharp")); + + result.Events.Should().NotContainErrors(); + + _kernel.FindKernelByName("csharp").As().TryGetValue("age", out var boundValue); + + boundValue.Should().Be("123"); + } + + [Fact] + public async Task Input_field_values_can_be_stored_using_SecretManager() + { + // make the secret name unique across runs + var secretName = nameof(Previously_stored_values_are_used_to_prepopulate_input_fields) + DateTime.UtcNow.Ticks; + + _kernel.RespondToRequestInputsFormWith(new Dictionary + { + ["name"] = "age", + ["value"] = "123" + }); + + var result = await _kernel.SendAsync( + new SubmitCode($$""" + #!set --name @input --value @input:{"saveAs": "{{secretName}}"} + """, "csharp")); + + result.Events.Should().NotContainErrors(); + + _secretManager.TryGetSecret(secretName, out var storedValue).Should().BeTrue(); + + storedValue.Should().Be("123"); + } + + [Fact] + public async Task Previously_stored_values_are_used_to_prepopulate_input_fields() + { + // make the secret name unique across runs + var secretName = nameof(Previously_stored_values_are_used_to_prepopulate_input_fields) + DateTime.UtcNow.Ticks; + + var theStoredValue = "the stored value"; + _secretManager.SetSecret(name: secretName, value: theStoredValue); + + _kernel.RespondToRequestInputsFormWith(new Dictionary + { + ["name"] = "age", + ["value"] = "123" + }); + + using var events = _kernel.KernelEvents.ToSubscribedList(); + + await _kernel.SendAsync( + new SubmitCode($$""" + #!shim --name @input --value @input:{"saveAs": "{{secretName}}"} + """, "csharp")); + + events.Should().NotContainErrors(); + + events.Should().ContainSingle() + .Which + .FormattedValues + .Should() + .ContainSingle() + .Which + .Value + .Should() + .Match($"* + { + ["name"] = "fruit", + ["value"] = "cherry" + }); + + RequestInput requestInputSent = null; + _kernel.AddMiddleware(async (command, context, next) => + { + if (command is RequestInput requestInput) + { + requestInputSent = requestInput; + } + + await next(command, context); + }); + + await _kernel.SendAsync( + new SubmitCode(""" + #!set --name @input:name --value @input:value + """, "csharp")); + + requestInputSent.Should().BeNull(); + } + + [Fact] + public async Task When_multiple_inputs_are_enabled_then_RequestInput_is_not_sent_for_a_magic_command_that_uses_JSON_binding() + { + _kernel.RespondToRequestInputsFormWith(new Dictionary + { + ["name"] = "fruit", + ["value"] = "cherry" + }); + + RequestInput requestInputSent = null; + _kernel.AddMiddleware(async (command, context, next) => + { + if (command is RequestInput requestInput) + { + requestInputSent = requestInput; + } + + await next(command, context); + }); + + await _kernel.SendAsync( + new SubmitCode(""" + #!shim --name @input --value @input + """, "csharp")); + + requestInputSent.Should().BeNull(); + } + + [Fact] + public async Task When_RequestInputs_is_not_supported_then_it_falls_back_to_sending_multiple_RequestInput_commands() + { + using var kernel = new CompositeKernel + { + new CSharpKernel() + .UseNugetDirective() + .UseKernelHelpers() + .UseValueSharing(), + new KeyValueStoreKernel() + }; + List receivedRequestInputs = []; + Queue responses = new(); + responses.Enqueue("one"); + responses.Enqueue("two"); + + kernel.RegisterCommandHandler((requestInput, context) => + { + receivedRequestInputs.Add(requestInput); + context.Publish(new InputProduced(responses.Dequeue(), requestInput)); + return Task.CompletedTask; + }); + + kernel.SetDefaultTargetKernelNameForCommand(typeof(RequestInput), _kernel.Name); + + var result = await kernel.SendAsync( + new SubmitCode(""" + #!set --name @input --value @password + """, "csharp")); + + result.Events.Should().NotContainErrors(); + + using var _ = new AssertionScope(); + + receivedRequestInputs.Should().HaveCount(2); + receivedRequestInputs[0].ParameterName.Should().Be("--name"); + receivedRequestInputs[1].ParameterName.Should().Be("--value"); + } + + [Theory] + [MemberData(nameof(LanguageServiceCommands))] + public async Task Language_service_commands_do_not_trigger_input_requests(KernelCommand command) + { + var result = await _kernel.SendAsync(command); + + _kernel.RespondToRequestInputsFormWith(new Dictionary + { + ["name"] = "fruit", + ["value"] = "cherry" + }); + + RequestInputs requestInputsSent = null; + _kernel.AddMiddleware(async (kernelCommand, context, next) => + { + if (kernelCommand is RequestInputs requestInput) + { + requestInputsSent = requestInput; + } + + await next(command, context); + }); + + result.Events.Should().NotContainErrors(); + + requestInputsSent.Should().BeNull(); + } + + public static IEnumerable LanguageServiceCommands() + { + var code = "#!set --name @input:name --value @password:password"; + + yield return [new RequestCompletions(code, new LinePosition(0, code.Length), targetKernelName: "csharp")]; + yield return [new RequestHoverText(code, new LinePosition(0, 3), targetKernelName: "csharp")]; + yield return [new RequestDiagnostics(code, targetKernelName: "csharp")]; + yield return [new RequestSignatureHelp(code, new LinePosition(0, 3), targetKernelName: "csharp")]; + + code = "#!shim --name @input:name --value @password:password"; + + yield return [new RequestCompletions(code, new LinePosition(0, code.Length), targetKernelName: "csharp")]; + yield return [new RequestHoverText(code, new LinePosition(0, 3), targetKernelName: "csharp")]; + yield return [new RequestDiagnostics(code, targetKernelName: "csharp")]; + yield return [new RequestSignatureHelp(code, new LinePosition(0, 3), targetKernelName: "csharp")]; + } +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Tests/RequestInputTests.cs b/src/Microsoft.DotNet.Interactive.Tests/RequestInputTests.cs index d4a5f74938..cccbef60c0 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/RequestInputTests.cs +++ b/src/Microsoft.DotNet.Interactive.Tests/RequestInputTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using FluentAssertions; @@ -108,17 +109,52 @@ public async Task When_a_saved_value_is_used_then_the_user_is_notified() .Match($"Using previously saved value for `{saveAs}`.*To remove this value *, run the following command in a PowerShell cell:*"); } + [Fact] + public async Task Multiple_inputs_can_be_requested_together() + { + var requestInputs = new RequestInputs + { + Inputs = + [ + new("Fruit"), + new("Tastiness"), + new("Color") + ] + }; + + using var kernel = CreateKernel() + .UseFormsForMultipleInputs(); + + var formValues = new Dictionary + { + ["Fruit"] = "cherry", + ["Tastiness"] = "9000", + ["Color"] = "red" + }; + + kernel.RespondToRequestInputsFormWith(formValues); + + var result = await kernel.SendAsync(requestInputs); + + result.Events.Should().NotContainErrors(); + + result.Events.Should().ContainSingle() + .Which.Values.Should().BeEquivalentTo(formValues); + } + private static CompositeKernel CreateKernel() { + var powerShellKernel = new PowerShellKernel(); + var kernel = new CompositeKernel { new CSharpKernel() .UseNugetDirective() .UseKernelHelpers() .UseValueSharing(), - new PowerShellKernel(), + powerShellKernel, new KeyValueStoreKernel() - }.UseSecretManager(); + }.UseSecretManager(new SecretManager(powerShellKernel)); kernel.SetDefaultTargetKernelNameForCommand(typeof(RequestInput), kernel.Name); diff --git a/src/Microsoft.DotNet.Interactive.Tests/InputsWithinMagicCommandsTests.cs b/src/Microsoft.DotNet.Interactive.Tests/SingleInputsWithinMagicCommandsTests.cs similarity index 85% rename from src/Microsoft.DotNet.Interactive.Tests/InputsWithinMagicCommandsTests.cs rename to src/Microsoft.DotNet.Interactive.Tests/SingleInputsWithinMagicCommandsTests.cs index 2eabd2a6cd..c5891aee2c 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/InputsWithinMagicCommandsTests.cs +++ b/src/Microsoft.DotNet.Interactive.Tests/SingleInputsWithinMagicCommandsTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Threading.Tasks; using FluentAssertions; +using FluentAssertions.Execution; using Microsoft.DotNet.Interactive.App; using Microsoft.DotNet.Interactive.Commands; using Microsoft.DotNet.Interactive.CSharp; @@ -15,7 +16,7 @@ namespace Microsoft.DotNet.Interactive.Tests; -public class InputsWithinMagicCommandsTests : IDisposable +public class SingleInputsWithinMagicCommandsTests : IDisposable { private readonly CompositeKernel _kernel; @@ -27,7 +28,7 @@ public class InputsWithinMagicCommandsTests : IDisposable private readonly Queue _responses = new(); - public InputsWithinMagicCommandsTests() + public SingleInputsWithinMagicCommandsTests() { _kernel = CreateKernel(); @@ -46,6 +47,9 @@ public InputsWithinMagicCommandsTests() Parameters = { new("--value") + { + AllowImplicitName = true + } } }; @@ -67,10 +71,19 @@ public void Dispose() _kernel.Dispose(); } - [Fact] - public async Task Input_token_in_magic_command_prompts_user_for_input() + [Theory] + [InlineData("#!shim @input")] + [InlineData("#!shim @input:input-please")] + [InlineData("#!shim --value @input:input-please")] + public async Task Input_token_in_magic_command_prompts_user_for_input_using_the_associated_parameter_name(string code) { - await _kernel.SendAsync(new SubmitCode("#!shim --value @input:input-please", "csharp")); + _responses.Enqueue("one"); + + var result = await _kernel.SendAsync(new SubmitCode(code, "csharp")); + + using var _ = new AssertionScope(); + + result.Events.Should().NotContainErrors(); _receivedRequestInput.IsPassword.Should().BeFalse(); @@ -80,17 +93,7 @@ public async Task Input_token_in_magic_command_prompts_user_for_input() .Which .Prompt .Should() - .Be("Please enter a value for field \"input-please\"."); - } - - [Fact] - public async Task Input_token_in_magic_command_prompts_user_passes_user_input_to_directive_to_handler() - { - _responses.Enqueue("one"); - - var result = await _kernel.SendAsync(new SubmitCode("#!shim --value @input:input-please", "csharp")); - - result.Events.Should().NotContainErrors(); + .Be("Please enter a value for parameter: --value"); _receivedUserInput.Should().ContainSingle().Which.Should().Be("one"); } @@ -122,7 +125,7 @@ public async Task Input_token_in_magic_command_prompts_user_for_password() .Which .Prompt .Should() - .Be("Please enter a value for field \"input-please\"."); + .Be("Please enter a value for parameter: --value"); } [Fact] @@ -225,22 +228,11 @@ public async Task Additional_properties_of_input_request_are_set_by_input_proper [MemberData(nameof(LanguageServiceCommands))] public async Task Language_service_commands_do_not_trigger_input_requests(KernelCommand command) { - using var kernel = new CSharpKernel().UseValueSharing(); - - bool requestInputWasSent = false; - - kernel.RegisterCommandHandler((input, _) => - { - requestInputWasSent = true; - - return Task.CompletedTask; - }); - - var result = await kernel.SendAsync(command); + var result = await _kernel.SendAsync(command); result.Events.Should().NotContainErrors(); - requestInputWasSent.Should().BeFalse(); + _receivedRequestInput.Should().BeNull(); } public static IEnumerable LanguageServiceCommands() @@ -248,17 +240,17 @@ public static IEnumerable LanguageServiceCommands() // Testing with both one and multiple inputs in a single magic command var code = "#!set --name @input:name --value 123"; - yield return [new RequestCompletions(code, new LinePosition(0, code.Length))]; - yield return [new RequestHoverText(code, new LinePosition(0, 3))]; - yield return [new RequestDiagnostics(code)]; - yield return [new RequestSignatureHelp(code, new LinePosition(0, 3))]; + yield return [new RequestCompletions(code, new LinePosition(0, code.Length), targetKernelName: "csharp")]; + yield return [new RequestHoverText(code, new LinePosition(0, 3), targetKernelName: "csharp")]; + yield return [new RequestDiagnostics(code, targetKernelName: "csharp")]; + yield return [new RequestSignatureHelp(code, new LinePosition(0, 3), targetKernelName: "csharp")]; code = "#!set --name @input:name --value @password:password "; - yield return [new RequestCompletions(code, new LinePosition(0, code.Length))]; - yield return [new RequestHoverText(code, new LinePosition(0, 3))]; - yield return [new RequestDiagnostics(code)]; - yield return [new RequestSignatureHelp(code, new LinePosition(0, 3))]; + yield return [new RequestCompletions(code, new LinePosition(0, code.Length), targetKernelName: "csharp")]; + yield return [new RequestHoverText(code, new LinePosition(0, 3), targetKernelName: "csharp")]; + yield return [new RequestDiagnostics(code, targetKernelName: "csharp")]; + yield return [new RequestSignatureHelp(code, new LinePosition(0, 3), targetKernelName: "csharp")]; } internal class TestCommand : KernelCommand @@ -267,12 +259,12 @@ internal class TestCommand : KernelCommand } private static CompositeKernel CreateKernel() => - new() + new CompositeKernel { new CSharpKernel() .UseNugetDirective() .UseKernelHelpers() .UseValueSharing(), new KeyValueStoreKernel() - }; + }.UseFormsForMultipleInputs(); } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Tests/Utility/KernelExtensions.cs b/src/Microsoft.DotNet.Interactive.Tests/Utility/KernelExtensions.cs index 6833354ca6..4ca502c596 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Utility/KernelExtensions.cs +++ b/src/Microsoft.DotNet.Interactive.Tests/Utility/KernelExtensions.cs @@ -1,7 +1,10 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; using FluentAssertions; using Microsoft.DotNet.Interactive.Commands; @@ -35,4 +38,26 @@ public static async Task RequestValueAsync(this Kernel kernel, st return commandResult.Events.OfType().First(); } + + public static void RespondToRequestInputsFormWith( + this CompositeKernel kernel, + Dictionary formValues) + { + var subscription = kernel.KernelEvents.Subscribe(e => + { + if (e is DisplayedValueProduced dvp) + { + // Grab the form id from the displayed value + var formId = Regex.Match( + dvp.FormattedValues.Single().Value, + "form id=\"([a-zA-Z0-9]*)\"").Groups[1].Value; + + var sendValue = new SendValue(formId, formValues, FormattedValue.CreateSingleFromObject(formValues, "application/json")); + + Task.Run(async () => await kernel.SendAsync(sendValue)); + } + }); + + kernel.RegisterForDisposal(subscription); + } } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive.Tests/Utility/Wait.cs b/src/Microsoft.DotNet.Interactive.Tests/Utility/Wait.cs index 7179ba72fc..20823ff688 100644 --- a/src/Microsoft.DotNet.Interactive.Tests/Utility/Wait.cs +++ b/src/Microsoft.DotNet.Interactive.Tests/Utility/Wait.cs @@ -2,12 +2,14 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using FluentAssertions.Execution; namespace Microsoft.DotNet.Interactive.Tests.Utility; +[DebuggerStepThrough] public static class Wait { public static void Until( diff --git a/src/Microsoft.DotNet.Interactive.VSCode/VSCodeClientKernelExtension.cs b/src/Microsoft.DotNet.Interactive.VSCode/VSCodeClientKernelExtension.cs index ec7568d8a9..c56a69fa3a 100644 --- a/src/Microsoft.DotNet.Interactive.VSCode/VSCodeClientKernelExtension.cs +++ b/src/Microsoft.DotNet.Interactive.VSCode/VSCodeClientKernelExtension.cs @@ -16,17 +16,20 @@ public static async Task LoadAsync(Kernel kernel) var hostKernel = await root.Host.ConnectProxyKernelOnDefaultConnectorAsync( "vscode", new Uri("kernel://vscode"), - new[] { "frontend" }); + ["frontend"]); hostKernel.KernelSpecifierDirective.Hidden = true; + hostKernel.KernelInfo.SupportedKernelCommands.Add(new(nameof(RequestInput))); root.SetDefaultTargetKernelNameForCommand(typeof(RequestInput), "vscode"); + hostKernel.KernelInfo.SupportedKernelCommands.Add(new(nameof(RequestInputs))); + root.SetDefaultTargetKernelNameForCommand(typeof(RequestInputs), "vscode"); hostKernel.KernelInfo.SupportedKernelCommands.Add(new(nameof(SendEditableCode))); root.SetDefaultTargetKernelNameForCommand(typeof(SendEditableCode), "vscode"); var jsKernel = await root.Host.ConnectProxyKernelOnDefaultConnectorAsync( "javascript", new Uri("kernel://webview/javascript"), - new[] { "js" }); + ["js"]); jsKernel.KernelInfo.SupportedKernelCommands.Add(new(nameof(SubmitCode))); jsKernel.KernelInfo.SupportedKernelCommands.Add(new(nameof(RequestValue))); jsKernel.KernelInfo.SupportedKernelCommands.Add(new(nameof(RequestValueInfos))); diff --git a/src/Microsoft.DotNet.Interactive/Commands/InputDescription.cs b/src/Microsoft.DotNet.Interactive/Commands/InputDescription.cs new file mode 100644 index 0000000000..5ed51cfcbf --- /dev/null +++ b/src/Microsoft.DotNet.Interactive/Commands/InputDescription.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json.Serialization; +using Microsoft.DotNet.Interactive.Parsing; + +namespace Microsoft.DotNet.Interactive.Commands; + +public class InputDescription +{ + public InputDescription(string name, string prompt = null) + { + Name = name; + Prompt = prompt ?? name; + } + + public string Name { get; } + + public string Prompt { get; } + + public string SaveAs { get; set; } + + [JsonPropertyName("type")] + public string TypeHint { get; set; } + + internal DirectiveExpressionNode ExpressionNode { get; set; } + + public string GetPropertyNameForJsonSerialization() => DirectiveNode.FromPosixStyleToCamelCase(Name); + + internal static InputDescription Parse(DirectiveExpressionNode expressionNode) + { + var requestInput = RequestInput.Parse(expressionNode); + + return new InputDescription(requestInput.ParameterName, requestInput.Prompt) + { + ExpressionNode = expressionNode, + SaveAs = requestInput.SaveAs, + TypeHint = requestInput.InputTypeHint + }; + } +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive/Commands/RequestInput.cs b/src/Microsoft.DotNet.Interactive/Commands/RequestInput.cs index 932ff7f81f..bf8b48aed8 100644 --- a/src/Microsoft.DotNet.Interactive/Commands/RequestInput.cs +++ b/src/Microsoft.DotNet.Interactive/Commands/RequestInput.cs @@ -1,7 +1,11 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System; +using System.Linq; +using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.DotNet.Interactive.Parsing; namespace Microsoft.DotNet.Interactive.Commands; @@ -10,20 +14,104 @@ public class RequestInput : KernelCommand [JsonConstructor] public RequestInput( string prompt, - string targetKernelName = null, string inputTypeHint = null) - : base(targetKernelName) { Prompt = prompt; InputTypeHint = inputTypeHint; } - public string Prompt { get; } + public string ParameterName { get; set; } + + public string Prompt { get; private set; } public bool IsPassword => InputTypeHint is "password"; + public string SaveAs { get; set; } + [JsonPropertyName("type")] public string InputTypeHint { get; set; } - public string SaveAs { get; set; } + internal static RequestInput Parse(DirectiveExpressionNode expressionNode) + { + if (expressionNode.ChildNodes.OfType().SingleOrDefault() is not { } expressionTypeNode) + { + throw new ArgumentException("Expression type not found"); + } + + var parametersNode = expressionNode.ChildNodes.OfType().SingleOrDefault(); + + var expressionType = expressionTypeNode.Type; + + var parametersNodeText = parametersNode?.Text; + + string parameterName = ""; + + if (expressionNode.Ancestors().OfType().FirstOrDefault() is { } parameterNode) + { + if (parameterNode.DescendantNodesAndTokens().OfType().FirstOrDefault() is { } parameterNameNode) + { + parameterName = parameterNameNode.Text; + } + else if (parameterNode.TryGetParameter(out var parameter) && + parameter.AllowImplicitName) + { + parameterName = parameter.Name; + } + } + + RequestInput requestInput; + + if (string.IsNullOrWhiteSpace(parametersNodeText)) + { + requestInput = new(prompt: $"Please enter a value for parameter: {parameterName}"); + } + else if (parametersNodeText?[0] is '{') + { + requestInput = JsonSerializer.Deserialize(parametersNode.Text, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (string.IsNullOrWhiteSpace(requestInput.Prompt)) + { + requestInput.Prompt = $"Please enter a value for parameter: {parameterName}"; + } + } + else + { + if (parametersNodeText?[0] is '"') + { + parametersNodeText = JsonSerializer.Deserialize(parametersNode.Text); + } + + if (parametersNodeText?.Contains(" ") is true) + { + requestInput = new(prompt: parametersNodeText); + } + else + { + requestInput = new(prompt: $"Please enter a value for parameter: {parameterName}"); + } + } + + requestInput.ParameterName = parameterName; + + if (expressionType is "password") + { + requestInput.InputTypeHint = "password"; + } + else if (string.IsNullOrEmpty(requestInput.InputTypeHint)) + { + if (expressionNode.Parent?.Parent is DirectiveParameterNode parameterValueNode) + { + if (parameterValueNode.TryGetParameter(out var parameter) && + parameter.TypeHint is { } typeHint) + { + requestInput.InputTypeHint = typeHint; + } + } + } + + return requestInput; + } } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive/Commands/RequestInputs.cs b/src/Microsoft.DotNet.Interactive/Commands/RequestInputs.cs new file mode 100644 index 0000000000..386f775347 --- /dev/null +++ b/src/Microsoft.DotNet.Interactive/Commands/RequestInputs.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Interactive.Commands; + +public class RequestInputs : KernelCommand +{ + private List _inputs; + + public List Inputs + { + get => _inputs ??= new(); + init => _inputs = value; + } +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive/Connection/KernelCommandAndEventReceiver.cs b/src/Microsoft.DotNet.Interactive/Connection/KernelCommandAndEventReceiver.cs index f2e43045bd..f811a3db42 100644 --- a/src/Microsoft.DotNet.Interactive/Connection/KernelCommandAndEventReceiver.cs +++ b/src/Microsoft.DotNet.Interactive/Connection/KernelCommandAndEventReceiver.cs @@ -8,6 +8,7 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using System.Threading; +using System.Threading.Tasks; using Microsoft.DotNet.Interactive.Commands; using Microsoft.DotNet.Interactive.Events; using Pocket; @@ -19,13 +20,13 @@ namespace Microsoft.DotNet.Interactive.Connection; public class KernelCommandAndEventReceiver : IKernelCommandAndEventReceiver, IDisposable { - private readonly ReadCommandOrEvent _readCommandOrEvent; + private readonly ReadCommandOrEventAsync _readCommandOrEvent; private readonly Subject _subject = new(); private readonly IObservable _observable; private readonly CompositeDisposable _disposables = new(); private CancellationTokenSource _cancellationTokenSource; - public KernelCommandAndEventReceiver(ReadCommandOrEvent readCommandOrEvent) + public KernelCommandAndEventReceiver(ReadCommandOrEventAsync readCommandOrEvent) { _readCommandOrEvent = readCommandOrEvent ?? throw new ArgumentNullException(nameof(readCommandOrEvent)); @@ -57,6 +58,23 @@ public KernelCommandAndEventReceiver(ReadCommandOrEvent readCommandOrEvent) .RefCount(); } + public KernelCommandAndEventReceiver(ReadCommandOrEvent readCommandOrEvent) : + this(async token => + { + if (!token.IsCancellationRequested) + { + var commandOrEvent = readCommandOrEvent(); + + return await Task.FromResult(commandOrEvent); + } + else + { + return default; + } + }) + { + } + private KernelCommandAndEventReceiver(IObservable messages) => _observable = messages .Select(s => @@ -79,7 +97,7 @@ private void ReaderLoop() { while (!_cancellationTokenSource.IsCancellationRequested) { - var message = _readCommandOrEvent(_cancellationTokenSource.Token); + var message = _readCommandOrEvent(_cancellationTokenSource.Token).GetAwaiter().GetResult(); if (message is not null) { @@ -122,11 +140,16 @@ public static KernelCommandAndEventReceiver FromObservable(IObservable m new(messages); public static KernelCommandAndEventReceiver FromTextReader(TextReader reader) => - new(_ => + new(async token => { try { - var json = reader.ReadLine(); +#if NETSTANDARD2_0 + var json = await reader.ReadLineAsync(); +#else + var timedCts = new CancellationTokenSource(1000); + var json = await reader.ReadLineAsync(timedCts.Token); +#endif if (!string.IsNullOrWhiteSpace(json)) { @@ -147,11 +170,11 @@ public static KernelCommandAndEventReceiver FromTextReader(TextReader reader) => }); public static KernelCommandAndEventReceiver FromNamedPipe(PipeStream stream) => - new(token => + new(async token => { if (stream.CanRead) { - var json = stream.ReadMessageAsync(token).GetAwaiter().GetResult(); + var json = await stream.ReadMessageAsync(token); var commandOrEvent = Serializer.DeserializeCommandOrEvent(json); diff --git a/src/Microsoft.DotNet.Interactive/Connection/KernelEventEnvelope.cs b/src/Microsoft.DotNet.Interactive/Connection/KernelEventEnvelope.cs index 21368591d7..2d43c12bb4 100644 --- a/src/Microsoft.DotNet.Interactive/Connection/KernelEventEnvelope.cs +++ b/src/Microsoft.DotNet.Interactive/Connection/KernelEventEnvelope.cs @@ -72,6 +72,7 @@ public static void RegisterDefaults() [nameof(IncompleteCodeSubmissionReceived)] = typeof(KernelEventEnvelope), [nameof(HoverTextProduced)] = typeof(KernelEventEnvelope), [nameof(InputProduced)] = typeof(KernelEventEnvelope), + [nameof(InputsProduced)] = typeof(KernelEventEnvelope), [nameof(KernelInfoProduced)] = typeof(KernelEventEnvelope), [nameof(KernelReady)] = typeof(KernelEventEnvelope), [nameof(PackageAdded)] = typeof(KernelEventEnvelope), diff --git a/src/Microsoft.DotNet.Interactive/Connection/ReadCommandOrEvent.cs b/src/Microsoft.DotNet.Interactive/Connection/ReadCommandOrEvent.cs index e5dbb7b729..9c177ee709 100644 --- a/src/Microsoft.DotNet.Interactive/Connection/ReadCommandOrEvent.cs +++ b/src/Microsoft.DotNet.Interactive/Connection/ReadCommandOrEvent.cs @@ -2,7 +2,10 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Threading; +using System.Threading.Tasks; namespace Microsoft.DotNet.Interactive.Connection; -public delegate CommandOrEvent ReadCommandOrEvent(CancellationToken cancellationToken = default); \ No newline at end of file +public delegate CommandOrEvent ReadCommandOrEvent(CancellationToken cancellationToken = default); + +public delegate Task ReadCommandOrEventAsync(CancellationToken cancellationToken = default); diff --git a/src/Microsoft.DotNet.Interactive/Connection/Serializer.cs b/src/Microsoft.DotNet.Interactive/Connection/Serializer.cs index af535f0b55..4caef1c7a1 100644 --- a/src/Microsoft.DotNet.Interactive/Connection/Serializer.cs +++ b/src/Microsoft.DotNet.Interactive/Connection/Serializer.cs @@ -53,25 +53,15 @@ public static CommandOrEvent DeserializeCommandOrEvent(JsonElement jsonObject) var kernelCommandEnvelope = KernelCommandEnvelope.Deserialize(jsonObject); return new CommandOrEvent(kernelCommandEnvelope.Command); } - } - private static bool IsEventEnvelope(JsonElement jsonObject) - { - if (jsonObject.TryGetProperty("eventType", out var eventType)) + static bool IsEventEnvelope(JsonElement jsonObject) { - return !string.IsNullOrWhiteSpace(eventType.GetString()); - } - - return false; - } + if (jsonObject.TryGetProperty("eventType", out var eventType)) + { + return !string.IsNullOrWhiteSpace(eventType.GetString()); + } - private static bool IsCommandEnvelope(JsonElement jsonObject) - { - if (jsonObject.TryGetProperty("commandType", out var commandType)) - { - return !string.IsNullOrWhiteSpace(commandType.GetString()); + return false; } - - return false; } } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive/Directives/ExpressionBindingResult.cs b/src/Microsoft.DotNet.Interactive/Directives/ExpressionBindingResult.cs index c6228e5142..6dabe71c62 100644 --- a/src/Microsoft.DotNet.Interactive/Directives/ExpressionBindingResult.cs +++ b/src/Microsoft.DotNet.Interactive/Directives/ExpressionBindingResult.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. #nullable enable -using System; using System.Collections.Generic; using Microsoft.DotNet.Interactive.Events; using Microsoft.DotNet.Interactive.Parsing; @@ -13,7 +12,7 @@ internal class ExpressionBindingResult { public Dictionary BoundValues { get; init; } = new(); - public CodeAnalysis.Diagnostic[] Diagnostics { get; init; } = Array.Empty(); + public CodeAnalysis.Diagnostic[] Diagnostics { get; init; } = []; public Dictionary InputsProduced { get; set; } = new(); diff --git a/src/Microsoft.DotNet.Interactive/Events/InputsProduced.cs b/src/Microsoft.DotNet.Interactive/Events/InputsProduced.cs new file mode 100644 index 0000000000..0fe5782bee --- /dev/null +++ b/src/Microsoft.DotNet.Interactive/Events/InputsProduced.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using Microsoft.DotNet.Interactive.Commands; + +namespace Microsoft.DotNet.Interactive.Events; + +public class InputsProduced : KernelEvent +{ + public InputsProduced(Dictionary values, RequestInputs command) + : base(command) + { + Values = values; + } + + public Dictionary Values { get; } +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive/ImmediateScheduler.cs b/src/Microsoft.DotNet.Interactive/ImmediateScheduler.cs index 68ad0fe904..dee0a6cf06 100644 --- a/src/Microsoft.DotNet.Interactive/ImmediateScheduler.cs +++ b/src/Microsoft.DotNet.Interactive/ImmediateScheduler.cs @@ -3,16 +3,25 @@ using System.Threading; using System.Threading.Tasks; +using Pocket; namespace Microsoft.DotNet.Interactive; internal class ImmediateScheduler : IKernelScheduler { + private static readonly Logger Log = new("KernelScheduler (fast)"); + public async Task RunAsync( T value, KernelSchedulerDelegate onExecuteAsync, string scope = "default", CancellationToken cancellationToken = default) { - return await onExecuteAsync(value); + using var logOp = Log.OnEnterAndConfirmOnExit(arg: value); + + var result = await onExecuteAsync(value); + + logOp.Succeed(); + + return result; } } \ No newline at end of file diff --git a/src/Microsoft.DotNet.Interactive/Kernel.Static.cs b/src/Microsoft.DotNet.Interactive/Kernel.Static.cs index 0b89e3ac0e..163df7cd74 100644 --- a/src/Microsoft.DotNet.Interactive/Kernel.Static.cs +++ b/src/Microsoft.DotNet.Interactive/Kernel.Static.cs @@ -76,20 +76,20 @@ private static async Task GetInputAsync( public static void CSS(string content) => // From https://stackoverflow.com/questions/524696/how-to-create-a-style-tag-with-javascript - Javascript($@" - var css = `{content}`, - head = document.head || document.getElementsByTagName('head')[0], - style = document.createElement('style'); - - head.appendChild(style); - - style.type = 'text/css'; - if (style.styleSheet){{ - // This is required for IE8 and below. - style.styleSheet.cssText = css; - }} else {{ - style.appendChild(document.createTextNode(css)); - }}"); + Javascript($$""" + var css = `{{content}}`, + head = document.head || document.getElementsByTagName('head')[0], + style = document.createElement('style'); + + head.appendChild(style); + + style.type = 'text/css'; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + """); public static void Javascript(string scriptContent) { diff --git a/src/Microsoft.DotNet.Interactive/Kernel.cs b/src/Microsoft.DotNet.Interactive/Kernel.cs index 402c5a6ac0..ffe7b9409e 100644 --- a/src/Microsoft.DotNet.Interactive/Kernel.cs +++ b/src/Microsoft.DotNet.Interactive/Kernel.cs @@ -315,7 +315,7 @@ public void AddDirective(KernelActionDirective directive, Func _kernelSpecifierDirective ??= new($"#!{Name}", Name); private void RegisterDirectiveCommandHandler( - KernelActionDirective directive, + KernelActionDirective directive, KernelCommandInvocation handler) { var fullDirectiveName = FullDirectiveName(directive); @@ -458,9 +458,10 @@ public async Task SendAsync( case RequestKernelInfo _: case RequestValue _: case RequestValueInfos _: + case SendValue _: case UpdateDisplayedValue _: - await RunOnFastPath(context, c, cancellationToken); + await RunOnFastPath(context, c, cancellationToken, skipDeferredCommands: true); break; default: @@ -492,7 +493,6 @@ await Scheduler.RunAsync( } catch (InvalidOperationException ex) { - // FIX: (SendAsync) Log.Warning($"Error while awaiting idle after sending {command}", ex); throw; } @@ -520,22 +520,26 @@ internal SchedulingScope SchedulingScope private async Task RunOnFastPath( KernelInvocationContext context, KernelCommand command, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool skipDeferredCommands = false) { - await RunDeferredCommandsAsync(context); + if (!skipDeferredCommands) + { + await RunDeferredCommandsAsync(context); + } await _fastPathScheduler.RunAsync( - command, - InvokePipelineAndCommandHandler, - command.SchedulingScope.ToString(), - cancellationToken: cancellationToken) - .ContinueWith(t => - { - if (t.IsCanceled) - { - context.Cancel(); - } - }, cancellationToken); + command, + InvokePipelineAndCommandHandler, + command.SchedulingScope.ToString(), + cancellationToken: cancellationToken) + .ContinueWith(t => + { + if (t.IsCanceled) + { + context.Cancel(); + } + }, cancellationToken); } private async Task RunDeferredCommandsAsync(KernelInvocationContext context) @@ -623,18 +627,23 @@ protected internal void SetScheduler(KernelScheduler> GetDeferredCommands(KernelCommand command, string scope) + private bool IsInSchedulingScope(KernelCommand command) { if (command.SchedulingScope is null) { - return Array.Empty(); + return false; } - if (!command.SchedulingScope.Contains(SchedulingScope)) + return command.SchedulingScope.Contains(SchedulingScope); + } + + private async Task> GetDeferredCommandsAsync(KernelCommand command, string scope) + { + if (!IsInSchedulingScope(command)) { return Array.Empty(); } diff --git a/src/Microsoft.DotNet.Interactive/KernelExtensions.cs b/src/Microsoft.DotNet.Interactive/KernelExtensions.cs index a4f8ce9523..15dda13d88 100644 --- a/src/Microsoft.DotNet.Interactive/KernelExtensions.cs +++ b/src/Microsoft.DotNet.Interactive/KernelExtensions.cs @@ -60,12 +60,11 @@ public static IEnumerable FindKernels(this Kernel kernel, Func predicate(c) ? new[] { kernel }.Concat(c.ChildKernels.Where(predicate)) : c.ChildKernels.Where(predicate), _ when predicate(kernel) => new[] { kernel }, - _ => Enumerable.Empty() + _ => [] }; } diff --git a/src/Microsoft.DotNet.Interactive/KernelInvocationContext.cs b/src/Microsoft.DotNet.Interactive/KernelInvocationContext.cs index fbe3309491..6fad9829a3 100644 --- a/src/Microsoft.DotNet.Interactive/KernelInvocationContext.cs +++ b/src/Microsoft.DotNet.Interactive/KernelInvocationContext.cs @@ -245,7 +245,7 @@ public void Publish(KernelEvent @event, bool publishOnAmbientContextOnly) var command = @event.Command; - if (HandlingKernel is { }) + if (HandlingKernel is not null) { @event.StampRoutingSlipAndLog(HandlingKernel.KernelInfo.Uri); } diff --git a/src/Microsoft.DotNet.Interactive/KernelScheduler.cs b/src/Microsoft.DotNet.Interactive/KernelScheduler.cs index 9a4be5e7ae..07255f874a 100644 --- a/src/Microsoft.DotNet.Interactive/KernelScheduler.cs +++ b/src/Microsoft.DotNet.Interactive/KernelScheduler.cs @@ -103,21 +103,6 @@ public async Task IdleAsync() await currentlyRunning.TaskCompletionSource.Task; } - // FIX: (IdleAsync) - Log.Info($"{nameof(IdleAsync)}: SignalAndWait on thread {Thread.CurrentThread.ManagedThreadId} with {_childOperationsBarrier.ParticipantCount} participants, {_childOperationsBarrier.ParticipantsRemaining} remaining"); - - switch (_childOperationsBarrier.ParticipantCount) - { - case 0: - break; - case 1: - break; - case 2: - break; - case 3: - break; - } - try { _childOperationsBarrier.SignalAndWait(); diff --git a/src/Microsoft.DotNet.Interactive/Parsing/DirectiveNode.cs b/src/Microsoft.DotNet.Interactive/Parsing/DirectiveNode.cs index ff89b33a4e..53eb3548a1 100644 --- a/src/Microsoft.DotNet.Interactive/Parsing/DirectiveNode.cs +++ b/src/Microsoft.DotNet.Interactive/Parsing/DirectiveNode.cs @@ -439,7 +439,7 @@ public string GetInvokedCommandPath() private static readonly Regex _kebabCaseRegex = new("-[\\w]", RegexOptions.Compiled); - private static string FromPosixStyleToCamelCase(string value) => + internal static string FromPosixStyleToCamelCase(string value) => _kebabCaseRegex.Replace( value.TrimStart('-'), m => m.ToString().TrimStart('-').ToUpper()); diff --git a/src/Microsoft.DotNet.Interactive/Parsing/SubmissionParser.cs b/src/Microsoft.DotNet.Interactive/Parsing/SubmissionParser.cs index 159a1ebeeb..823fd4cc39 100644 --- a/src/Microsoft.DotNet.Interactive/Parsing/SubmissionParser.cs +++ b/src/Microsoft.DotNet.Interactive/Parsing/SubmissionParser.cs @@ -129,7 +129,7 @@ private async Task> SplitSubmission( { case { Kind: DirectiveNodeKind.Action }: - if (await CreateActionDirectiveCommand(directiveNode, targetKernelName) is { } actionDirectiveCommand) + if (await CreateActionDirectiveCommand(directiveNode) is { } actionDirectiveCommand) { commands.Add(actionDirectiveCommand); } @@ -142,10 +142,9 @@ private async Task> SplitSubmission( .OfType() .SingleOrDefault(); - if (valueNode.ChildTokens.FirstOrDefault(t => t is { Kind: TokenKind.Word }) is SyntaxToken firstWordToken && - firstWordToken.Text is "nuget") + if (valueNode.ChildTokens.FirstOrDefault(t => t is { Kind: TokenKind.Word }) is { Text: "nuget" }) { - if (await CreateActionDirectiveCommand(directiveNode, targetKernelName) is { } actionDirectiveCmd) + if (await CreateActionDirectiveCommand(directiveNode) is { } actionDirectiveCmd) { directiveCommand = actionDirectiveCmd; directiveCommand.SchedulingScope = lastCommandScope; @@ -175,7 +174,7 @@ private async Task> SplitSubmission( kernelSpecifierDirective.TryGetKernelCommandAsync is not null) { directiveCommand = await kernelSpecifierDirective.TryGetKernelCommandAsync( - directiveNode, + directiveNode, await RequestAllInputsAndKernelValues(directiveNode, originalCommand), _kernel); @@ -335,7 +334,7 @@ [new FormattedValue(PlainTextFormatter.MimeType, diagnostics.ToString())], })); } - async Task CreateActionDirectiveCommand(DirectiveNode directiveNode, string targetKernelName) + async Task CreateActionDirectiveCommand(DirectiveNode directiveNode) { if (!directiveNode.TryGetActionDirective(out var directive)) { @@ -356,7 +355,7 @@ async Task CreateActionDirectiveCommand(DirectiveNode directiveNo } } - if (directive.TryGetKernelCommandAsync is not null && + if (directive.TryGetKernelCommandAsync is not null && // This indicates that JSON serialization/deserialization of the command from the directive syntax is overridden by custom binding. await directive.TryGetKernelCommandAsync( directiveNode, await RequestAllInputsAndKernelValues(directiveNode, originalCommand), @@ -375,23 +374,34 @@ await RequestAllInputsAndKernelValues(directiveNode, originalCommand), } } + var formBindingResult = await RequestMultipleInputsIfAppropriate(directiveNode, originalCommand); + + DirectiveBindingResult serializedCommandResult + = await directiveNode.TryGetJsonAsync( + async expressionNode => + { + if (formBindingResult is not null) + { + if (formBindingResult.BoundValues.FirstOrDefault(v => v.Key.ExpressionNode == expressionNode) is var boundValue) + { + return DirectiveBindingResult.Success(boundValue.Value); + } + } + + var (bindingResult, _, _) = await RequestSingleValueOrInputAsync( + expressionNode, + originalCommand, + targetKernelName); + + return bindingResult; + }); + // Get command JSON and deserialize. - var directiveJsonResult = await directiveNode.TryGetJsonAsync( - async expressionNode => - { - var (boundValue, _, _) = await RequestSingleValueOrInputAsync( - expressionNode, - originalCommand, - targetKernelName); - - return boundValue; - }); - - if (directiveJsonResult.IsSuccessful) + if (serializedCommandResult.IsSuccessful) { try { - var commandEnvelope = KernelCommandEnvelope.Deserialize(directiveJsonResult.Value); + var commandEnvelope = KernelCommandEnvelope.Deserialize(serializedCommandResult.Value); var directiveCommand = commandEnvelope.Command; @@ -421,7 +431,7 @@ await RequestAllInputsAndKernelValues(directiveNode, originalCommand), } } - ClearCommandsAndFail(directiveJsonResult.Diagnostics.ToArray()); + ClearCommandsAndFail(serializedCommandResult.Diagnostics.ToArray()); return null; } } @@ -472,7 +482,59 @@ internal static (string targetKernelName, string promptOrValueName) SplitKernelD return (targetKernelName, valueName); } - internal async Task<(DirectiveBindingResult boundValue, ValueProduced valueProduced, InputProduced inputProduced)> RequestSingleValueOrInputAsync( + private async Task RequestMultipleInputsIfAppropriate( + DirectiveNode directiveNode, + KernelCommand sourceCommand) + { + if (sourceCommand is not SubmitCode) + { + return null; + } + + if (!_kernel.RootKernel.SupportsCommandType(typeof(RequestInputs))) + { + return null; + } + + ExpressionBindingResult formBindingResult = null; + + var expressionNodes = directiveNode.DescendantNodesAndTokens() + .OfType() + .ToArray(); + + if (expressionNodes.Length > 1) + { + formBindingResult = new ExpressionBindingResult(); + + var requestInputs = new RequestInputs + { + Inputs = expressionNodes.Where(n => n.TypeNode?.Type is "input" or "password") + .Select(InputDescription.Parse) + .ToList() + }; + + requestInputs.SetParent(sourceCommand); + + var result = await _kernel.SendAsync(requestInputs); + + if (result.Events.OfType().SingleOrDefault() is { } inputsProduced) + { + foreach (var input in requestInputs.Inputs) + { + var inputName = input.GetPropertyNameForJsonSerialization(); + if (inputsProduced.Values.TryGetValue(inputName, out var value)) + { + var directiveParameterValueNode = (DirectiveParameterValueNode)input.ExpressionNode.Parent!; + formBindingResult.BoundValues.Add(directiveParameterValueNode, value); + } + } + } + } + + return formBindingResult; + } + + internal async Task<(DirectiveBindingResult bindingResult, ValueProduced valueProduced, InputProduced inputProduced)> RequestSingleValueOrInputAsync( DirectiveExpressionNode expressionNode, KernelCommand command, string targetKernelName) @@ -488,9 +550,8 @@ internal static (string targetKernelName, string promptOrValueName) SplitKernelD { if (command is SubmitCode) { - var parametersNode = expressionNode.ChildNodes.OfType().SingleOrDefault(); - - var (bindingResult, inputProduced) = await RequestSingleInput(expressionNode, parametersNode, expressionType); + var requestInput = RequestInput.Parse(expressionNode); + var (bindingResult, inputProduced) = await RequestSingleInput(requestInput, expressionNode); return (bindingResult, null, inputProduced); } else @@ -658,54 +719,9 @@ sourceKernel is not null && } private async Task<(DirectiveBindingResult boundValue, InputProduced inputProduced)> RequestSingleInput( - DirectiveExpressionNode expressionNode, - DirectiveExpressionParametersNode parametersNode, - string expressionType) + RequestInput requestInput, + DirectiveExpressionNode expressionNode) { - var parametersNodeText = parametersNode?.Text; - - RequestInput requestInput; - - if (parametersNodeText?[0] is '{') - { - requestInput = JsonSerializer.Deserialize(parametersNode.Text, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - } - else - { - if (parametersNodeText?[0] is '"') - { - parametersNodeText = JsonSerializer.Deserialize(parametersNode.Text); - } - - if (parametersNodeText?.Contains(" ") is true) - { - requestInput = new(prompt: parametersNodeText); - } - else - { - requestInput = new(prompt: $"Please enter a value for field \"{parametersNodeText}\"."); - } - } - - if (expressionType is "password") - { - requestInput.InputTypeHint = "password"; - } - else if (string.IsNullOrEmpty(requestInput.InputTypeHint)) - { - if (expressionNode.Parent?.Parent is DirectiveParameterNode parameterValueNode) - { - if (parameterValueNode.TryGetParameter(out var parameter) && - parameter.TypeHint is { } typeHint) - { - requestInput.InputTypeHint = typeHint; - } - } - } - var result = await _kernel.SendAsync(requestInput); switch (result.Events[^1]) @@ -748,10 +764,20 @@ private async Task RequestAllInputsAndKernelValues( Dictionary inputsProduced = null; Dictionary valuesProduced = null; + var formBindindResult = await RequestMultipleInputsIfAppropriate(directiveNode, sourceCommand); + var (boundValues, diagnostics) = await directiveNode.TryBindExpressionsAsync( async expressionNode => { + if (formBindindResult is not null) + { + if (formBindindResult.BoundValues.FirstOrDefault(v => v.Key.ExpressionNode == expressionNode) is var boundValue) + { + return DirectiveBindingResult.Success(boundValue.Value); + } + } + var (bindingResult, valueProduced, inputProduced) = await RequestSingleValueOrInputAsync( expressionNode, @@ -779,7 +805,7 @@ await RequestSingleValueOrInputAsync( var result = new ExpressionBindingResult { BoundValues = boundValues, - Diagnostics = diagnostics, + Diagnostics = diagnostics }; if (inputsProduced is not null) diff --git a/src/Microsoft.DotNet.Interactive/ValueSharing/SetDirectiveCommand.cs b/src/Microsoft.DotNet.Interactive/ValueSharing/SetDirectiveCommand.cs index 416bec81e3..185b0b8675 100644 --- a/src/Microsoft.DotNet.Interactive/ValueSharing/SetDirectiveCommand.cs +++ b/src/Microsoft.DotNet.Interactive/ValueSharing/SetDirectiveCommand.cs @@ -78,6 +78,7 @@ public static Task TryParseSetDirectiveCommandAsync( if (bindingResult.InputsProduced.TryGetValue("--value", out var inputProduced)) { + // FIX: (TryParseSetDirectiveCommandAsync) can this be a BoundValue and remove InputsProduced property? if (((RequestInput)inputProduced.Command).IsPassword) { command.ReferenceValue = new PasswordString(inputProduced.Value); diff --git a/src/dotnet-interactive/CommandLine/CommandLineParser.cs b/src/dotnet-interactive/CommandLine/CommandLineParser.cs index 99dd1f85f0..dfe4eec60c 100644 --- a/src/dotnet-interactive/CommandLine/CommandLineParser.cs +++ b/src/dotnet-interactive/CommandLine/CommandLineParser.cs @@ -212,7 +212,7 @@ Command Jupyter() async Task JupyterHandler(StartupOptions startupOptions, JupyterOptions options, IConsole console, InvocationContext context, CancellationToken cancellationToken) { var frontendEnvironment = new HtmlNotebookFrontendEnvironment(); - var kernel = CreateKernel(options.DefaultKernel, frontendEnvironment, startupOptions, telemetrySender); + var kernel = KernelBuilder.CreateKernel(options.DefaultKernel, frontendEnvironment, startupOptions, telemetrySender); cancellationToken.Register(kernel.Dispose); await JupyterClientKernelExtension.LoadAsync(kernel); @@ -330,7 +330,7 @@ console is TestConsole ? new HtmlNotebookFrontendEnvironment() : new BrowserFrontendEnvironment(); - var kernel = CreateKernel( + var kernel = KernelBuilder.CreateKernel( options.DefaultKernel, frontendEnvironment, startupOptions, @@ -451,84 +451,6 @@ static HttpPortRange ParsePortRangeOption(ArgumentResult result) } } - private static CompositeKernel CreateKernel( - string defaultKernelName, - FrontendEnvironment frontendEnvironment, - StartupOptions startupOptions, - TelemetrySender telemetrySender) - { - using var _ = Log.OnEnterAndExit("Creating Kernels"); - - var compositeKernel = new CompositeKernel(); - compositeKernel.FrontendEnvironment = frontendEnvironment; - - // TODO: temporary measure to support vscode integrations - compositeKernel.Add(new SqlDiscoverabilityKernel()); - compositeKernel.Add(new KqlDiscoverabilityKernel()); - - compositeKernel.Add( - new CSharpKernel() - .UseNugetDirective() - .UseKernelHelpers() - .UseWho() - .UseMathAndLaTeX() - .UseValueSharing(), - new[] { "c#", "C#" }); - - compositeKernel.Add( - new FSharpKernel() - .UseDefaultFormatting() - .UseNugetDirective() - .UseKernelHelpers() - .UseWho() - .UseMathAndLaTeX() - .UseValueSharing(), - new[] { "f#", "F#" }); - - compositeKernel.Add( - new PowerShellKernel() - .UseProfiles() - .UseValueSharing(), - new[] { "powershell" }); - - compositeKernel.Add( - new HtmlKernel()); - - compositeKernel.Add( - new KeyValueStoreKernel() - .UseWho()); - - compositeKernel.Add( - new MermaidKernel()); - - compositeKernel.Add( - new HttpKernel() - .UseValueSharing()); - - var kernel = compositeKernel - .UseDefaultMagicCommands() - .UseAboutMagicCommand() - .UseImportMagicCommand() - .UseSecretManager() - .UseNuGetExtensions(telemetrySender); - - kernel.AddKernelConnector(new ConnectSignalRDirective()); - kernel.AddKernelConnector(new ConnectStdIoDirective(startupOptions.KernelHost)); - - kernel.AddKernelConnector( - new ConnectJupyterKernelDirective() - .AddConnectionOptions(new JupyterHttpKernelConnectionOptions()) - .AddConnectionOptions(new JupyterLocalKernelConnectionOptions())); - - SetUpFormatters(frontendEnvironment); - - kernel.DefaultKernelName = defaultKernelName; - - kernel.UseTelemetrySender(telemetrySender); - - return kernel; - } - public static void SetUpFormatters(FrontendEnvironment frontendEnvironment) { switch (frontendEnvironment) diff --git a/src/dotnet-interactive/KernelBuilder.cs b/src/dotnet-interactive/KernelBuilder.cs new file mode 100644 index 0000000000..2752a2ee03 --- /dev/null +++ b/src/dotnet-interactive/KernelBuilder.cs @@ -0,0 +1,100 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.DotNet.Interactive.App.CommandLine; +using Microsoft.DotNet.Interactive.App.Connection; +using Microsoft.DotNet.Interactive.CSharp; +using Microsoft.DotNet.Interactive.FSharp; +using Microsoft.DotNet.Interactive.Http; +using Microsoft.DotNet.Interactive.Jupyter; +using Microsoft.DotNet.Interactive.Mermaid; +using Microsoft.DotNet.Interactive.PowerShell; +using Microsoft.DotNet.Interactive.Telemetry; +using Pocket; + +namespace Microsoft.DotNet.Interactive.App; + +public static class KernelBuilder +{ + internal static CompositeKernel CreateKernel( + string defaultKernelName, + FrontendEnvironment frontendEnvironment, + StartupOptions startupOptions, + TelemetrySender telemetrySender) + { + using var _ = Logger.Log.OnEnterAndExit("Creating Kernels"); + + var compositeKernel = new CompositeKernel(); + compositeKernel.FrontendEnvironment = frontendEnvironment; + + // TODO: temporary measure to support vscode integrations + compositeKernel.Add(new SqlDiscoverabilityKernel()); + compositeKernel.Add(new KqlDiscoverabilityKernel()); + + compositeKernel.Add( + new CSharpKernel() + .UseNugetDirective() + .UseKernelHelpers() + .UseWho() + .UseMathAndLaTeX() + .UseValueSharing(), + new[] { "c#", "C#" }); + + compositeKernel.Add( + new FSharpKernel() + .UseDefaultFormatting() + .UseNugetDirective() + .UseKernelHelpers() + .UseWho() + .UseMathAndLaTeX() + .UseValueSharing(), + new[] { "f#", "F#" }); + + var powerShellKernel = new PowerShellKernel() + .UseProfiles() + .UseValueSharing(); + compositeKernel.Add( + powerShellKernel, + new[] { "powershell" }); + + compositeKernel.Add( + new HtmlKernel()); + + compositeKernel.Add( + new KeyValueStoreKernel() + .UseWho()); + + compositeKernel.Add( + new MermaidKernel()); + + compositeKernel.Add( + new HttpKernel() + .UseValueSharing()); + + var secretManager = new SecretManager(powerShellKernel); + + var kernel = compositeKernel + .UseDefaultMagicCommands() + .UseAboutMagicCommand() + .UseImportMagicCommand() + .UseSecretManager(secretManager) + .UseFormsForMultipleInputs(secretManager) + .UseNuGetExtensions(telemetrySender); + + kernel.AddKernelConnector(new ConnectSignalRDirective()); + kernel.AddKernelConnector(new ConnectStdIoDirective(startupOptions.KernelHost)); + + kernel.AddKernelConnector( + new ConnectJupyterKernelDirective() + .AddConnectionOptions(new JupyterHttpKernelConnectionOptions()) + .AddConnectionOptions(new JupyterLocalKernelConnectionOptions())); + + CommandLineParser.SetUpFormatters(frontendEnvironment); + + kernel.DefaultKernelName = defaultKernelName; + + kernel.UseTelemetrySender(telemetrySender); + + return kernel; + } +} \ No newline at end of file diff --git a/src/dotnet-interactive/KernelExtensions.cs b/src/dotnet-interactive/KernelExtensions.cs index 1367729812..f32ecd9dd8 100644 --- a/src/dotnet-interactive/KernelExtensions.cs +++ b/src/dotnet-interactive/KernelExtensions.cs @@ -2,9 +2,12 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Microsoft.DotNet.Interactive.Commands; using Microsoft.DotNet.Interactive.CSharp; @@ -21,6 +24,106 @@ namespace Microsoft.DotNet.Interactive.App; public static class KernelExtensions { + public static TKernel UseFormsForMultipleInputs( + this TKernel kernel, + SecretManager secretManager = null) + where TKernel : Kernel + { + if (kernel.SupportsCommandType(typeof(SendValue))) + { + throw new InvalidOperationException($"A command handler for {nameof(SendValue)} is already registered on kernel {kernel.Name}."); + } + + var barrier = new Barrier(2); + kernel.RegisterForDisposal(barrier); + ConcurrentDictionary receivedValues = new(StringComparer.OrdinalIgnoreCase); + + kernel.RegisterCommandHandler(async (requestInputs, context) => + { + var formId = Guid.NewGuid().ToString("N"); + + var inputDescriptions = requestInputs.Inputs; + + PocketView html = div( + form[id: formId]( + inputDescriptions.Select(GetHtmlForSingleInput), + button[onclick: $"event.preventDefault(); sendSendValueCommand(document.getElementById('{formId}'));"]("Ok"))); + + PocketView GetHtmlForSingleInput(InputDescription inputDescription) + { + var inputName = inputDescription.GetPropertyNameForJsonSerialization(); + + var value = ""; + + if (inputDescription.SaveAs is not null && + secretManager is not null) + { + secretManager.TryGetSecret(inputDescription.SaveAs, out value); + } + + return div( + label[@for: inputName](inputDescription.Prompt), + br, + input[ + "required", + type: inputDescription.TypeHint, + id: inputName, + name: inputName, + value: value, + onkeydown: "event.stopPropagation()" // prevent event bubbling from triggering (for example) key commands in VS Code + ]()); + } + + context.Display(html); + + await Task.Yield(); + + barrier.SignalAndWait(context.CancellationToken); + + if (receivedValues.TryGetValue(formId, out var formattedValue)) + { + var values = JsonSerializer.Deserialize>(formattedValue.Value); + + if (secretManager is not null) + { + foreach (var inputDescription in inputDescriptions) + { + if (inputDescription.SaveAs is not null) + { + if (values.TryGetValue(inputDescription.GetPropertyNameForJsonSerialization(), out var value)) + { + secretManager.SetSecret(inputDescription.SaveAs, value); + } + } + } + } + + context.Publish(new InputsProduced( + values, + requestInputs)); + } + else + { + context.Fail(requestInputs, message: "No input received."); + } + }); + kernel.RegisterCommandHandler((sendValue, context) => + { + receivedValues[sendValue.Name] = sendValue.FormattedValue; + + // don't wait on the barrier if the form hasn't been displayed + if (barrier.ParticipantsRemaining == 1) + { + barrier.SignalAndWait(context.CancellationToken); + } + + return Task.CompletedTask; + }); + + return kernel; + } + + public static CSharpKernel UseNugetDirective(this CSharpKernel kernel, bool forceRestore = false) { kernel.UseNugetDirective((k, resolvedPackageReference) => @@ -110,10 +213,12 @@ public static T UseAboutMagicCommand(this T kernel) return kernel; } - public static CompositeKernel UseSecretManager(this CompositeKernel kernel) + public static CompositeKernel UseSecretManager(this CompositeKernel kernel, SecretManager secretManager) { - PowerShellKernel powerShellKernel = null; - SecretManager secretManager = null; + if (secretManager is null) + { + throw new ArgumentNullException(nameof(secretManager)); + } kernel.AddMiddleware(async (command, context, next) => { @@ -123,22 +228,6 @@ public static CompositeKernel UseSecretManager(this CompositeKernel kernel) return; } - if (secretManager is null) - { - powerShellKernel = kernel.ChildKernels.OfType().FirstOrDefault(); - - if (powerShellKernel is not null) - { - secretManager = new(powerShellKernel); - } - else - { - // FIX: (UseSecretManager) what's the best thing to do here? maybe silently ignore? display a warning? - await next(command, context); - return; - } - } - if (secretManager.TryGetSecret(requestInput.SaveAs, out var value)) { context.Publish(new InputProduced(value, requestInput)); diff --git a/src/polyglot-notebooks-vscode-common/src/extension.ts b/src/polyglot-notebooks-vscode-common/src/extension.ts index 2d3754dee9..ab0032a414 100644 --- a/src/polyglot-notebooks-vscode-common/src/extension.ts +++ b/src/polyglot-notebooks-vscode-common/src/extension.ts @@ -151,10 +151,7 @@ export async function activate(context: vscode.ExtensionContext) { const environmentVariables = { ...polyglotConfig.get<{ [key: string]: string }>('kernelEnvironmentVariables'), 'DOTNET_CLI_CULTURE': getCurrentCulture(), 'DOTNET_CLI_UI_LANGUAGE': getCurrentCulture() }; const processStart = processArguments(argsTemplate, workingDirectory, DotNetPathManager.getDotNetPath(), launchOptions!.workingDirectory, environmentVariables); - let notification = { - displayError: async (message: string) => { await vscode.window.showErrorMessage(message, { modal: false }); }, - displayInfo: async (message: string) => { await vscode.window.showInformationMessage(message, { modal: false }); }, - }; + const channel = new StdioDotnetInteractiveChannel(notebookUri.toString(), processStart, diagnosticsChannel, (pid, code, signal) => { clientMapper.closeClient(notebookUri, false); }); @@ -170,7 +167,6 @@ export async function activate(context: vscode.ExtensionContext) { return vscode.env.language; } - function configureKernel(compositeKernel: CompositeKernel, notebookUri: vscodeLike.Uri) { compositeKernel.setDefaultTargetKernelNameForCommand(commandsAndEvents.RequestInputType, compositeKernel.name); compositeKernel.setDefaultTargetKernelNameForCommand(commandsAndEvents.SendEditableCodeType, compositeKernel.name); @@ -178,7 +174,8 @@ export async function activate(context: vscode.ExtensionContext) { This allows adding new cells to the notebook and prompting user for input.`; compositeKernel.registerCommandHandler({ - commandType: commandsAndEvents.RequestInputType, handle: async (commandInvocation) => { + commandType: commandsAndEvents.RequestInputType, + handle: async (commandInvocation) => { const requestInput = commandInvocation.commandEnvelope.command; const prompt = requestInput.prompt; const password = requestInput.isPassword; @@ -209,7 +206,8 @@ export async function activate(context: vscode.ExtensionContext) { }); compositeKernel.registerCommandHandler({ - commandType: commandsAndEvents.SendEditableCodeType, handle: async commandInvocation => { + commandType: commandsAndEvents.SendEditableCodeType, + handle: async commandInvocation => { const addCell = commandInvocation.commandEnvelope.command; const kernelName = addCell.kernelName; const contents = addCell.code; diff --git a/src/polyglot-notebooks/src/contracts.ts b/src/polyglot-notebooks/src/contracts.ts index 49cc9b22d0..0e63f82e29 100644 --- a/src/polyglot-notebooks/src/contracts.ts +++ b/src/polyglot-notebooks/src/contracts.ts @@ -19,6 +19,7 @@ export const RequestCompletionsType = "RequestCompletions"; export const RequestDiagnosticsType = "RequestDiagnostics"; export const RequestHoverTextType = "RequestHoverText"; export const RequestInputType = "RequestInput"; +export const RequestInputsType = "RequestInputs"; export const RequestKernelInfoType = "RequestKernelInfo"; export const RequestSignatureHelpType = "RequestSignatureHelp"; export const RequestValueType = "RequestValue"; @@ -43,6 +44,7 @@ export type KernelCommandType = | typeof RequestDiagnosticsType | typeof RequestHoverTextType | typeof RequestInputType + | typeof RequestInputsType | typeof RequestKernelInfoType | typeof RequestSignatureHelpType | typeof RequestValueType @@ -116,10 +118,15 @@ export interface RequestHoverText extends LanguageServiceCommand { export interface RequestInput extends KernelCommand { inputTypeHint: string; isPassword: boolean; + parameterName: string; prompt: string; saveAs: string; } +export interface RequestInputs extends KernelCommand { + inputs: Array; +} + export interface RequestKernelInfo extends KernelCommand { } @@ -235,6 +242,7 @@ export const ErrorProducedType = "ErrorProduced"; export const HoverTextProducedType = "HoverTextProduced"; export const IncompleteCodeSubmissionReceivedType = "IncompleteCodeSubmissionReceived"; export const InputProducedType = "InputProduced"; +export const InputsProducedType = "InputsProduced"; export const KernelExtensionLoadedType = "KernelExtensionLoaded"; export const KernelInfoProducedType = "KernelInfoProduced"; export const KernelReadyType = "KernelReady"; @@ -262,6 +270,7 @@ export type KernelEventType = | typeof HoverTextProducedType | typeof IncompleteCodeSubmissionReceivedType | typeof InputProducedType + | typeof InputsProducedType | typeof KernelExtensionLoadedType | typeof KernelInfoProducedType | typeof KernelReadyType @@ -340,6 +349,10 @@ export interface InputProduced extends KernelEvent { value: string; } +export interface InputsProduced extends KernelEvent { + values: { [key: string]: string; }; +} + export interface KernelExtensionLoaded extends KernelEvent { } @@ -439,6 +452,13 @@ export interface FormattedValue { value: string; } +export interface InputDescription { + name: string; + prompt: string; + saveAs: string; + typeHint: string; +} + export interface InteractiveDocument { elements: Array; metadata: { [key: string]: any; }; diff --git a/src/polyglot-notebooks/src/webview/frontEndHost.ts b/src/polyglot-notebooks/src/webview/frontEndHost.ts index fd6f5774b3..0086f0993c 100644 --- a/src/polyglot-notebooks/src/webview/frontEndHost.ts +++ b/src/polyglot-notebooks/src/webview/frontEndHost.ts @@ -35,13 +35,40 @@ export function createHost( }); // use composite kernel as root - global.kernel = { get root() { return compositeKernel; } }; + global.sendSendValueCommand = (form: any) => { + let formValues: any = {}; + + for (var i = 0; i < form.elements.length; i++) { + var e = form.elements[i]; + + if (e.name && e.name !== '') { + let name = e.name.replace('-', ''); + formValues[name] = e.value; + } + } + + let command = { + formattedValue: { + mimeType: 'application/json', + value: JSON.stringify(formValues) + }, + name: form.id, + targetKernelName: '.NET' + }; + + let envelope = new commandsAndEvents.KernelCommandEnvelope(commandsAndEvents.SendValueType, command); + + form.remove(); + + compositeKernel.send(envelope); + }; + global[compositeKernelName] = { compositeKernel, kernelHost,